diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 00:01:04 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 64b6a0dbdaab4b314ccbce667709d71a8561daf0 (patch) | |
| tree | ceb955449a4458675e7d3bccca4565bd469039da | |
| parent | b71a9051006f88052d240e521046428c4e1c5e15 (diff) | |
| download | pardes-64b6a0dbdaab4b314ccbce667709d71a8561daf0.tar.gz pardes-64b6a0dbdaab4b314ccbce667709d71a8561daf0.zip | |
The tree serves the layout: /layout, /tag and /col/<n>/tag
A script could not see where the panes sit or edit the tags a person
clicks in. /layout lists each column, its index, x and width in cells,
current or not, empty or full, and its panes' serials, then the active
column; /tag is the workspace tag and /col/<n>/tag a column's, read and
written as a pane tag is (one line: a newline written in is a space, and a
truncating write drops the one ending it); /index's lines end with the
pane's column; the log says newcol <n> and delcol <n>. acme serves no
column files, and the docs say so.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 4 | ||||
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | docs/fs.md | 16 | ||||
| -rw-r--r-- | src/fs-help.txt | 5 | ||||
| -rw-r--r-- | src/fs.zig | 5 | ||||
| -rw-r--r-- | src/layout.zig | 11 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 163 | ||||
| -rw-r--r-- | src/ninep/events.zig | 4 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 15 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 111 | ||||
| -rw-r--r-- | test/selfmount.py | 6 |
11 files changed, 315 insertions, 30 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index b16039d2..a5447991 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -52,7 +52,7 @@ match this document, that session predates the change; restart it. ``` $m/README the served guide, worth reading first -$m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name +$m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column $m/status pid, version, panes $m/look write a line = a right click on it at the active pane (file:/re/, file:#n, :/re/ select by address, as acme's look, from the file's dot: file:0/re/ for the @@ -82,6 +82,8 @@ $m/ctl the settings, one a line as a write takes them; write a setting or a pane's builtins (Del, Save f, Collapse, which folds that pane, and the column word Delcol, which closes that pane's column) go to $m/pane/<n>/ctl $m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it) +$m/layout one line per column: index x width current|notcurrent empty|full serials...; active <index> +$m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<n>/tag a column's, n from 0 $m/pane/new open it to make a pane (a scratch named <dir>/+New), read names it; it goes in the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules); @@ -31,7 +31,10 @@ to refresh. Mini snapshots survive Dump/Restore without rereading the source. On Linux, `Tty9p` (`SPC n 9`) opens a terminal with the session's 9P tree mounted through kernel v9fs. It asks sudo in that pane, then starts your shell as your normal user. Access the tree through `$PARDES_MOUNT`. -See [mounted terminals](docs/v9fs.md). +See [mounted terminals](docs/v9fs.md). Beyond acme's per-window files the tree +serves the layout: `/layout` lists the columns, their places and panes, and +`/tag` and `/col/<n>/tag` are the workspace and column tags, editable like a +window's ([the tree](docs/fs.md#the-served-tree)). ## Requirements @@ -92,7 +92,7 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. ``` /README this guide, also src/fs-help.txt -/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name +/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name, column /status pid, version and pane count /look write a line: a right click on it at the active pane; read: the serials the last look, exec or ctl write touched (made, else acted at) @@ -105,6 +105,10 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /focus the serial of the pane with the keyboard; write a serial to give it the keyboard /ctl the settings, one a line as a write takes them; write a setting or a session builtin /commands every builtin: word, `arg` if it takes one, `root` or `pane` (the ctl that takes it), a setting's values +/layout one line per column: index x width current|notcurrent empty|full serials...; + then active <index> (- when no column has the keyboard) +/tag the workspace tag; > replaces it, >> appends, one line +/col/<n>/tag column n's tag, the same way; columns count from 0, left to right /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 @@ -112,6 +116,16 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. /src/ the editor's embedded sources, only when built with -Dembed-sources=true ``` +`/layout`, `/tag` and `/col` go past acme, which serves no column files -- +its columns are only where a window sits. They are here so a script can see +where the panes are (`/index`'s last word is each one's column, the same +index) and edit the tags a person clicks in: a column tag is one line, a +newline written into it a space, and a truncating write (`echo Make > +col/0/tag`) clears it and drops the newline that ends it, as a pane tag's +does. A column is named by its index, so the columns right of one that +closes move down one; the log says `newcol <n>` and `delcol <n>` as they come +and go, with /layout to read again. + Control messages are split by what they act on, as acme keeps window verbs on a window's ctl and webfs and upas/fs keep session settings on a root ctl. Each builtin declares its scope in src/builtins.zig (`scope = .session`; diff --git a/src/fs-help.txt b/src/fs-help.txt index 0121a831..2f944c8e 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -1,7 +1,7 @@ Pardes control filesystem ========================= README this guide -index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed) +index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed), column status pid, version and pane count look write a line: a right click on it at the active pane; read: the serials it touched exec write a line: a middle click, an editor command word or a shell line; read the same @@ -11,6 +11,9 @@ listeners the session's dial addresses focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word) commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it +layout one line per column: index x width current|notcurrent empty|full serials...; then active <index> +tag the workspace tag: read it, > replaces it, >> appends (one line) +col/<n>/tag a column's tag, the same way (columns count from 0, as in layout) pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes @@ -1270,6 +1270,11 @@ pub const Namespace = struct { /// What went wrong performing a write's effects after it was answered /// by the core (a save the host could not do): the 9P write, which /// waits for them, fails with it (src/9p_io.zig). + /// A header tag truncated by an open not yet released (ninep/cols.zig): + /// 0 the workspace's, a column's its index + 1; and whether the newline + /// that ended its last write is held back. + header_rewrite: ?u32 = null, + header_held: bool = false, late_failure: [256]u8 = undefined, late_failure_len: u16 = 0, /// A refusal that quotes the message it refuses, as Plan 9's cmderror diff --git a/src/layout.zig b/src/layout.zig index c58f3bcf..6de7a912 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -280,7 +280,7 @@ pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { b.vweight = pair - a.vweight; } -pub fn findPane(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } { +pub fn findPane(p: *const Pardes, id: usize) ?struct { col: usize, idx: usize } { for (0..p.ncol) |c| { for (0..p.col_n[c]) |k| { if (p.col_panes[c][k] == id) return .{ .col = c, .idx = k }; @@ -325,6 +325,14 @@ pub fn dropColumn(p: *Pardes, c: usize) void { p.ncol -= 1; p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; + noteColumn(p, "delcol", c); +} + +/// `newcol <n>` or `delcol <n>` in the log: the index, as /layout and +/// /col number columns, the ones after it moving up or down one. +fn noteColumn(p: *Pardes, what: []const u8, c: usize) void { + var buf: [16]u8 = undefined; + pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d}", .{c}) catch return); } /// Fold column `c` into the one on its right, its panes below theirs. @@ -480,6 +488,7 @@ pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { p.col_serial[c] = 0; p.col_n[c] = 0; p.ncol += 1; + noteColumn(p, "newcol", c); return c; } diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig new file mode 100644 index 00000000..e1688517 --- /dev/null +++ b/src/ninep/cols.zig @@ -0,0 +1,163 @@ +//! The layout and the header tags: /layout, /tag (the workspace's) and +//! /col/<n>/tag (each column's). acme serves no column files -- its columns +//! are only a window's place -- so these go past it: a script can see where +//! the panes are and edit the tags a person clicks in. +const std = @import("std"); +const pardes = @import("../pardes.zig"); +const Pardes = pardes.Pardes; +const tree = @import("tree.zig"); +const tagline = pardes.tagline; +const layout = pardes.layout; +const Text = pardes.panes.Text; +const Req = tree.Req; +const Reply = tree.Reply; +const E = tree.E; + +/// The column with the keyboard: the header's, when a column tag has it, +/// else the active pane's. Null when the workspace tag has it and no pane +/// is active. +pub fn activeColumn(p: *const Pardes) ?usize { + if (p.header_focus) if (p.header_column) |c| return if (c < p.ncol) c else null; + const at = layout.findPane(p, p.active) orelse return null; + return at.col; +} + +/// One line a column: its index, x and width in cells, `current` or +/// `notcurrent` (rio's words, for the column with the keyboard), `empty` or +/// `full`, and its panes' serials, top to bottom; then `active <index>`. +pub fn writeLayout(p: *const Pardes, w: *std.Io.Writer) !void { + const active = activeColumn(p); + for (0..p.ncol) |c| { + try w.print("{d} {d} {d} {s} {s}", .{ + c, p.col_x[c], p.col_w[c], + if (active == c) "current" else "notcurrent", if (p.col_n[c] == 0) "empty" else "full", + }); + for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| try w.print(" {d}", .{pane.serial}); + try w.writeByte('\n'); + } + if (active) |c| try w.print("active {d}\n", .{c}) else try w.writeAll("active -\n"); +} + +pub fn layoutLen(p: *const Pardes) u64 { + var buf: [256]u8 = undefined; + var counting: std.Io.Writer.Discarding = .init(&buf); + writeLayout(p, &counting.writer) catch return 0; + return counting.fullCount(); +} + +/// The header a node names: the workspace's (`col` null) or a column's. +fn header(p: *Pardes, col: ?usize) ?*Text { + const c = col orelse return &p.global_tag; + return if (c < p.ncol) &p.col_tags[c] else null; +} + +pub fn headerText(p: *Pardes, col: ?usize) ?[]const u8 { + const c = col orelse return tagline.topbar(p); + return if (c < p.ncol) tagline.columnTag(p, c) else null; +} + +pub fn read(p: *Pardes, req: Req, col: ?usize) Reply { + const text = headerText(p, col) orelse return Reply.fail(req.tag, E.NOENT); + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + out.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +/// A write appends, as a pane tag's does; the tag is one line, so a newline +/// in it becomes a space (tagline.flattenHeaders), but after a truncation +/// the newline that ends the write is dropped, as a pane tag drops it. +pub fn write(p: *Pardes, req: Req, col: ?usize) Reply { + const t = header(p, col) orelse return Reply.fail(req.tag, E.NOENT); + if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + const was = headerText(p, col).?; + var data = req.data; + const rewriting = p.fs.header_rewrite == rewriteKey(col); + // A newline held from the write before goes in once more text follows. + const held: []const u8 = if (rewriting and p.fs.header_held) " " else ""; + p.fs.header_held = false; + if (rewriting and data[data.len - 1] == '\n') { + data = data[0 .. data.len - 1]; + p.fs.header_held = true; + } + const next = std.mem.concat(p.gpa, u8, &.{ was, held, data }) catch return Reply.fail(req.tag, E.NOMEM); + std.mem.replaceScalar(u8, next, '\n', ' '); + if (t.own) |own| p.gpa.free(own); + t.own = next; + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +/// Truncating clears it, as a pane tag's `cleartag`: its default words go +/// too, and `u` in it brings them back. +pub fn truncate(p: *Pardes, col: ?usize) tree.Status { + const t = header(p, col) orelse return .err; + const empty = p.gpa.alloc(u8, 0) catch return .err; + t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null); + if (t.own) |own| p.gpa.free(own); + t.own = empty; + t.cur_row = 0; + t.cur_col = 0; + p.fs.header_rewrite = rewriteKey(col); + p.fs.header_held = false; + return .ok; +} + +/// Which header a truncation was of: the workspace's is 0, a column's its +/// index + 1. +fn rewriteKey(col: ?usize) u32 { + return if (col) |c| @intCast(c + 1) else 0; +} + +pub fn released(p: *Pardes, col: ?usize) void { + if (p.fs.header_rewrite != rewriteKey(col)) return; + p.fs.header_rewrite = null; + p.fs.header_held = false; +} + +// ---- tests ---- + +const testing = std.testing; +const th = @import("testing.zig"); +const Node = tree.Node; + +test "layout lists each column's place, panes and state, and the active one" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.newColumn(p.active); + p.sync(); + const layout_node = @intFromEnum(tree.TopFile.layout); + const text = th.rd(p, layout_node, 0, 4096).bytes; + var want: [256]u8 = undefined; + const first = try std.fmt.bufPrint(&want, "0 {d} {d} notcurrent full {d}\n", .{ p.col_x[0], p.col_w[0], p.panes[0].?.serial }); + try testing.expect(std.mem.startsWith(u8, text, first)); + // Newcol gives the new, empty column's tag the keyboard. + try testing.expect(std.mem.indexOf(u8, text, "current empty\n") != null); + try testing.expect(std.mem.endsWith(u8, text, "active 1\n")); + try testing.expectEqual(@as(u64, text.len), th.call(p, .{ .tag = 1, .op = .getattr, .node = layout_node }).reply.attr.size); + // /index says which column each pane is in, last. + try testing.expect(std.mem.endsWith(u8, th.rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, " 0\n")); + try testing.expect(th.logHas(p, "newcol 1\n")); +} + +test "a column's tag and the workspace's read, replace and append as a pane tag does, on one line" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const col_dir = @intFromEnum(tree.TopFile.col); + try testing.expectEqual(tree.Status.ok, th.look_up(p, col_dir, "0").reply.status); + try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "7").errno()); + const tag = Node.ofCol(0, .tag); + try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(0, .dir), "tag").reply.status); + try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol\n", th.rd(p, tag, 0, 4096).bytes); + // `echo Make > tag`: cleared, then the newline ending the write dropped. + _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); + _ = th.wr(p, tag, "Make\n"); + try testing.expectEqualStrings("Make\n", th.rd(p, tag, 0, 4096).bytes); + _ = th.call(p, .{ .tag = 2, .op = .release, .node = tag }); + // `>>` appends; a newline in a one-line tag is a space. + _ = th.wr(p, tag, " Lint\nNow"); + try testing.expectEqualStrings("Make Lint Now\n", th.rd(p, tag, 0, 4096).bytes); + const top = @intFromEnum(tree.TopFile.tag); + _ = th.call(p, .{ .tag = 3, .op = .setattr, .node = top, .truncate = true }); + _ = th.wr(p, top, "Newcol Exit\n"); + try testing.expectEqualStrings("Newcol Exit", tagline.topbar(p)); +} diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 0e489be7..fc410d5a 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -172,6 +172,10 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) { if (tree.Node.target(req.node)) |t| switch (t) { .top => |f| file = f.fileName(), + .col => |c| { + serial = "-"; + file = std.fmt.bufPrint(&name, "col/{d}/tag", .{c.index}) catch "col"; + }, .pane => |pt| { serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-"; file = switch (pt.file) { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 3eafdbce..6542d747 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -265,6 +265,12 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { // ---- the index ---- +/// The column a pane is in, as /layout and /col number them; the last word +/// of its /index line. +pub fn columnOf(p: *const Pardes, id: usize) usize { + return if (pardes.layout.findPane(p, id)) |at| at.col else 0; +} + pub fn indexLen(p: *Pardes) u64 { var n: u64 = 0; var last: u32 = 0; @@ -274,6 +280,7 @@ pub fn indexLen(p: *Pardes) u64 { var digits: [16]u8 = undefined; n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len; n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1; + n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } return n; } @@ -703,7 +710,7 @@ 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; - const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}); + const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt 0\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 }); @@ -712,7 +719,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" { pane.file.?.saved_revision = pane.file.?.revision -% 1; var dirty_want: [64]u8 = undefined; - const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt\n", .{pane.serial}); + const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt 0\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); @@ -722,7 +729,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" { try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p)); var expected: [4200]u8 = undefined; const made = p.panes[p.paneBySerial(serial).?].?; - try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s}\n", .{ serial, nameOf(made) }))); + try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(made), columnOf(p, p.paneBySerial(serial).?) }))); } test "a terminal is listed as term with its directory as name" { @@ -734,7 +741,7 @@ test "a terminal is listed as term with its directory as name" { 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}), + try std.fmt.bufPrint(&want, "{d} term 0 /work/dir 0\n", .{pane.serial}), ) != null); try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes); const refused = wr(p, Node.of(pane.serial, .name), "/elsewhere\n"); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 7279b64a..9459be4f 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -18,6 +18,7 @@ pub const events = @import("events.zig"); pub const pane = @import("pane.zig"); pub const addr = @import("addr.zig"); pub const ctl = @import("ctl.zig"); +pub const cols = @import("cols.zig"); pub const pty = @import("pty.zig"); pub const sources = @import("sources.zig"); pub const screen = @import("screen.zig"); @@ -171,7 +172,7 @@ pub fn openOf(p: *Pardes, req: Req) ?*Open { // ---- nodes ---- -pub const TopFile = enum(u4) { +pub const TopFile = enum(u5) { root = 1, README, index, @@ -186,6 +187,9 @@ pub const TopFile = enum(u4) { focus, ctl, commands, + layout, + tag, + col, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); @@ -193,14 +197,14 @@ pub const TopFile = enum(u4) { pub fn mode(f: TopFile) u16 { return switch (f) { - .root, .pane => 0o755, - .look, .exec, .log, .focus, .ctl => 0o666, - .README, .index, .status, .screen, .listeners, .new, .commands => 0o444, + .root, .pane, .col => 0o755, + .look, .exec, .log, .focus, .ctl, .tag => 0o666, + .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444, }; } pub fn dir(f: TopFile) bool { - return f == .root or f == .pane; + return f == .root or f == .pane or f == .col; } }; @@ -264,6 +268,11 @@ pub const PaneFile = enum(u5) { } }; +/// /col/<n> and its tag: a column is named by its index, which a serial +/// never is (serials are 32 bits; this bit is past them). +pub const ColFile = enum(u5) { dir = 0, tag = 1 }; +const col_bit: u59 = 1 << 58; + pub const Node = packed struct(u64) { file: u5 = 0, serial: u59 = 0, @@ -273,11 +282,19 @@ pub const Node = packed struct(u64) { return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial }); } + pub fn ofCol(index: usize, file: ColFile) u64 { + return @bitCast(Node{ .file = @intFromEnum(file), .serial = col_bit | @as(u59, @intCast(index)) }); + } + pub fn target(node: u64) ?Target { const n: Node = @bitCast(node); if (n.serial == 0) { return .{ .top = std.enums.fromInt(TopFile, n.file) orelse return null }; } + if (n.serial & col_bit != 0) return .{ .col = .{ + .index = std.math.cast(u32, n.serial & ~col_bit) orelse return null, + .file = std.enums.fromInt(ColFile, n.file) orelse return null, + } }; return .{ .pane = .{ .serial = std.math.cast(u32, n.serial) orelse return null, .file = std.enums.fromInt(PaneFile, n.file) orelse return null, @@ -288,6 +305,7 @@ pub const Node = packed struct(u64) { pub const Target = union(enum) { top: TopFile, pane: struct { serial: u32, file: PaneFile }, + col: struct { index: u32, file: ColFile }, }; /// Opening /pane/new makes a pane and reading the open fid answers its @@ -388,7 +406,7 @@ pub fn handle(p: *Pardes, req: Req) Reply { const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), - .top => 'F', + .top, .col => 'F', }; return switch (req.op) { .lookup => lookup(p, req, target), @@ -410,7 +428,7 @@ pub fn handle(p: *Pardes, req: Req) Reply { fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr { const t = switch (target) { .pane => |t| t, - .top => return null, + .top, .col => return null, }; if (t.file != .pty_run) return null; const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| { @@ -429,6 +447,19 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .mode = f.mode(), .size = topSize(p, f), .mtime = p.fs.started, + .append = f == .tag, + }, + .col => |c| { + if (c.index >= p.ncol) return null; + return .{ + .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.index}) catch unreachable) else "tag", + .node = Node.ofCol(c.index, c.file), + .dir = c.file == .dir, + .mode = if (c.file == .dir) 0o755 else 0o666, + .size = if (c.file == .tag) cols.headerText(p, c.index).?.len + 1 else 0, + .mtime = p.fs.started, + .append = c.file == .tag, + }; }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return null; @@ -461,7 +492,9 @@ fn topSize(p: *Pardes, f: TopFile) u64 { return switch (f) { // 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, + .root, .pane, .col, .screen, .new => 0, + .layout => cols.layoutLen(p), + .tag => cols.headerText(p, null).?.len + 1, .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), @@ -492,6 +525,7 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply { .pty => Node.of(t.serial, .dir), else => return Reply.fail(req.tag, E.NOTDIR), }, + .col => |c| if (c.file == .dir) @intFromEnum(TopFile.col) else return Reply.fail(req.tag, E.NOTDIR), }; return handle(p, .{ .tag = req.tag, .op = .getattr, .node = parent }); } @@ -514,8 +548,19 @@ fn lookup(p: *Pardes, req: Req, target: Target) Reply { if (serial == 0) return Reply.fail(req.tag, E.NOENT); break :pane Node.of(serial, .dir); }, + // A column is its index, in decimal digits. + .col => col: { + if (name.len > 4) return Reply.fail(req.tag, E.NOENT); + for (name) |c| if (c < '0' or c > '9') return Reply.fail(req.tag, E.NOENT); + break :col Node.ofCol(std.fmt.parseInt(u32, name, 10) catch return Reply.fail(req.tag, E.NOENT), .dir); + }, else => return Reply.fail(req.tag, E.NOTDIR), }, + .col => |c| col: { + if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR); + if (!std.mem.eql(u8, name, "tag")) return Reply.fail(req.tag, E.NOENT); + break :col Node.ofCol(c.index, .tag); + }, .pane => |t| pane: { _ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const f = switch (t.file) { @@ -555,7 +600,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { .root => { // The guide, then what describes the session, then the // files that act, then the directories. - inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |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"); @@ -575,8 +620,21 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); } }, + .col => for (0..p.ncol) |c| { + if (skip > 0) { + skip -= 1; + continue; + } + var buf: [16]u8 = undefined; + stageDirent(out, p.gpa, Node.ofCol(c, .dir), true, std.fmt.bufPrint(&buf, "{d}", .{c}) catch unreachable); + }, else => return Reply.fail(req.tag, E.NOTDIR), }, + .col => |c| { + if (c.index >= p.ncol) return Reply.fail(req.tag, E.NOENT); + if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR); + if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, Node.ofCol(c.index, .tag), false, "tag"); + }, .pane => |t| { const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); const terminal = p.panes[id].?.isTerminal(); @@ -610,6 +668,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { .log => .{ .log = .{} }, else => return .{ .tag = req.tag }, }, + .col => |c| return if (c.index < p.ncol) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT), .pane => |t| what: { const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); @@ -665,7 +724,7 @@ 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), + .top, .col => 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); @@ -690,6 +749,8 @@ fn releaseHandle(p: *Pardes, req: Req) void { p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = false; }; + if (t == .col and t.col.file == .tag) cols.released(p, t.col.index); + if (t == .top and t.top == .tag) cols.released(p, null); const o = openOf(p, req) orelse return; const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null; if (of) |pn| switch (o.what) { @@ -725,7 +786,8 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply { return failText(req.tag, E.INVAL, pane.e_addr_failed); if (pane.truncate(p, p.panes[id].?, t.file) != .ok) return Reply.fail(req.tag, E.NOMEM); }, - .top => {}, + .col => |c| if (c.file == .tag and cols.truncate(p, c.index) != .ok) return Reply.fail(req.tag, E.NOENT), + .top => |f| if (f == .tag and cols.truncate(p, null) != .ok) return Reply.fail(req.tag, E.NOMEM), }; return .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) }; } @@ -770,8 +832,9 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { var last: u32 = 0; while (nextSerialAfter(p, last)) |serial| { last = serial; - const pn = p.panes[p.paneBySerial(serial).?].?; - out.print(p.gpa, "{d} {s} {d} {s}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn) }) catch {}; + const id = p.paneBySerial(serial).?; + const pn = p.panes[id].?; + out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn), pane.columnOf(p, id) }) catch {}; } break :index stagedReply(p, req); }, @@ -783,6 +846,13 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { .focus => ctl.readFocus(p, req), .ctl => ctl.readRoot(p, req), .commands => ctl.readCommands(p, req), + .layout => layout: { + var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); + cols.writeLayout(p, &text.writer) catch break :layout Reply.fail(req.tag, E.NOMEM); + p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :layout Reply.fail(req.tag, E.NOMEM); + break :layout stagedReply(p, req); + }, + .tag => cols.read(p, req, null), // 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: { @@ -791,7 +861,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { break :serial Reply.fail(req.tag, E.NOMEM); break :serial stagedReply(p, req); }, - .root, .pane => Reply.fail(req.tag, E.PERM), + .root, .pane, .col => Reply.fail(req.tag, E.PERM), }, .pane => |t| { // A run's answer outlives the pane it ran in. @@ -807,6 +877,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); return pane.read(p, req, id, pn, t.file); }, + .col => |c| return if (c.file == .tag) cols.read(p, req, c.index) else Reply.fail(req.tag, E.PERM), } } @@ -817,6 +888,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { .log => events.writeLog(p, req), .focus => ctl.writeFocus(p, req), .ctl => ctl.writeRoot(p, req), + .tag => cols.write(p, req, null), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { @@ -825,6 +897,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); return pane.write(p, req, id, pn, t.file); }, + .col => |c| return if (c.file == .tag) cols.write(p, req, c.index) else Reply.fail(req.tag, E.PERM), } } @@ -836,6 +909,7 @@ test { _ = addr; _ = ctl; _ = pty; + _ = cols; _ = sources; _ = screen; } @@ -968,12 +1042,13 @@ test "readdir lists the root and a pane directory without creating anything" { @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 >= 10); - for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { + for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 11, top[i].dir); + try testing.expectEqual(i >= 13, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[11].node); - try testing.expectEqual(fs.os_root, top[12].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node); + try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node); + try testing.expectEqual(fs.os_root, top[15].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); diff --git a/test/selfmount.py b/test/selfmount.py index cc889a93..90960bed 100644 --- a/test/selfmount.py +++ b/test/selfmount.py @@ -75,7 +75,7 @@ def run(binary): mount = f'/mnt/9p/pardes/{name}' with Client(sock) as client: index = client.read('/index').decode() - rows = [row.split(maxsplit=3) for row in index.splitlines()] + rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in index.splitlines()] first = rows[0][0] # A Look at the tree's own index: realpath, stat and read all go # out through 9ns and back in, and the write returns when the @@ -84,7 +84,7 @@ def run(binary): client.write('/look', (mount + '/index\n').encode()) took = time.monotonic() - started check(took < 5, f'look at the mounted index returned ({took:.2f}s)') - rows = [row.split(maxsplit=3) for row in client.read('/index').decode().splitlines()] + rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()] opened = [row for row in rows if len(row) == 4 and row[3] == mount + '/index'] check(len(opened) == 1, 'a pane opened on the mounted index') if opened: @@ -94,7 +94,7 @@ def run(binary): # and a Save: the save writes back through the mount into pane 1, # and the exec returns once it has. client.write('/look', f'{mount}/pane/{first}/body\n'.encode()) - rows = [row.split(maxsplit=3) for row in client.read('/index').decode().splitlines()] + rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()] mirror = [row for row in rows if len(row) == 4 and row[3] == f'{mount}/pane/{first}/body'] check(len(mirror) == 1, 'a pane opened on the mounted body') if mirror: |
