summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md4
-rw-r--r--README.md5
-rw-r--r--docs/fs.md16
-rw-r--r--src/fs-help.txt5
-rw-r--r--src/fs.zig5
-rw-r--r--src/layout.zig11
-rw-r--r--src/ninep/cols.zig163
-rw-r--r--src/ninep/events.zig4
-rw-r--r--src/ninep/pane.zig15
-rw-r--r--src/ninep/tree.zig111
-rw-r--r--test/selfmount.py6
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);
diff --git a/README.md b/README.md
index 267b5ca7..e487bf9c 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/fs.md b/docs/fs.md
index 89a513e8..5f382e7e 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -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
diff --git a/src/fs.zig b/src/fs.zig
index 0a4473d6..21ccc94e 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -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: