summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/cols.zig284
-rw-r--r--src/ninep/ctl.zig142
-rw-r--r--src/ninep/events.zig8
-rw-r--r--src/ninep/pane.zig61
-rw-r--r--src/ninep/screen.zig22
-rw-r--r--src/ninep/tree.zig197
6 files changed, 654 insertions, 60 deletions
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
new file mode 100644
index 00000000..b6dc7d26
--- /dev/null
+++ b/src/ninep/cols.zig
@@ -0,0 +1,284 @@
+//! The layout and the header tags: /layout, /tag (the workspace's) and
+//! /col/<serial>/tag (each column's; a column's serial stays while it lives,
+//! as a pane's does). 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 serial (the name /col/<serial> and the log give
+/// it, which stays while it lives), its index left to right, 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 <serial>`: the active column, where pane/new and a look
+/// place a pane next (acme's activecol), not always the keyboard's.
+pub fn writeLayout(p: *Pardes, w: *std.Io.Writer) !void {
+ const active = activeColumn(p);
+ for (0..p.ncol) |c| {
+ try w.print("{d} {d} {d} {d} {s} {s}", .{
+ layout.columnSerial(p, c), 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 (p.activeColumn() orelse active) |c| try w.print("active {d}\n", .{layout.columnSerial(p, c)}) else try w.writeAll("active -\n");
+}
+
+pub fn layoutLen(p: *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();
+}
+
+/// A header by its node: the workspace's (`serial` null) or the column's
+/// with that serial; null when that column has closed.
+const Header = struct { text: *Text, col: ?usize };
+
+fn header(p: *Pardes, serial: ?u32) ?Header {
+ const s = serial orelse return .{ .text = &p.global_tag, .col = null };
+ const c = layout.columnBySerial(p, s) orelse return null;
+ return .{ .text = &p.col_tags[c], .col = c };
+}
+
+pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 {
+ const h = header(p, serial) orelse return null;
+ return if (h.col) |c| tagline.columnTag(p, c) else tagline.topbar(p);
+}
+
+pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
+ const text = headerText(p, serial) 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, serial: ?u32) Reply {
+ const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text;
+ if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
+ const was = headerText(p, serial).?;
+ var data = req.data;
+ const rewriting = p.fs.header_rewrite == rewriteKey(serial);
+ // 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, serial: ?u32) tree.Status {
+ const t = (header(p, serial) orelse return .err).text;
+ 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(serial);
+ p.fs.header_held = false;
+ return .ok;
+}
+
+/// Which header a truncation was of: the workspace's is 0 (no column's
+/// serial is), a column's its serial.
+fn rewriteKey(serial: ?u32) u32 {
+ return serial orelse 0;
+}
+
+pub fn released(p: *Pardes, serial: ?u32) void {
+ if (p.fs.header_rewrite != rewriteKey(serial)) return;
+ p.fs.header_rewrite = null;
+ p.fs.header_held = false;
+}
+
+/// A column's ctl: the words that act on a column, as its tag's clicks do --
+/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag.
+pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
+ var lines = std.mem.splitScalar(u8, req.data, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len];
+ const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
+ if (std.mem.eql(u8, w, word)) break true;
+ } else false;
+ if (!takes) return tree.failText(req.tag, E.INVAL, "a column's ctl takes Delcol, Joincol, New or Tty");
+ const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ p.exec_column = col;
+ defer p.exec_column = null;
+ if (pardes.ctlfs.ctl.runBuiltin(p, req, p.active, line)) |refusal| return refusal;
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// A column's exec: each line run as a middle click on it in that column's
+/// tag, acting on that column.
+pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply {
+ var lines = std.mem.splitScalar(u8, req.data, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0) continue;
+ for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
+ const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ p.exec_column = col;
+ defer p.exec_column = null;
+ pardes.ctlfs.ctl.run(p, p.active, true, line);
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+/// rmdir of a column closes it when it is empty; one with panes is
+/// refused, saying so (close them, or Delcol on its ctl, which asks first).
+pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply {
+ if (file != .dir) return Reply.fail(req.tag, E.PERM);
+ const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ if (p.col_n[col] > 0) return tree.failText(req.tag, E.NOTEMPTY, "column not empty: close its panes, or write Delcol to its ctl");
+ p.removeColumn(col) catch return Reply.fail(req.tag, E.IO);
+ return .{ .tag = req.tag };
+}
+
+// ---- 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 first_serial = layout.columnSerial(p, 0);
+ const new_serial = layout.columnSerial(p, 1);
+ try testing.expect(first_serial != new_serial);
+ 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, "{d} 0 {d} {d} notcurrent full {d}\n", .{ first_serial, 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, try std.fmt.bufPrint(&want, "active {d}\n", .{new_serial})));
+ 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, by its serial, last.
+ try testing.expect(std.mem.endsWith(u8, th.rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, try std.fmt.bufPrint(&want, " {d}\n", .{first_serial})));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "newcol {d}\n", .{new_serial})));
+ // A column that closes takes its name with it; the others keep theirs.
+ try p.removeColumn(1);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "delcol {d}\n", .{new_serial})));
+ try testing.expectEqual(E.NOENT, th.look_up(p, @intFromEnum(tree.TopFile.col), try std.fmt.bufPrint(&want, "{d}", .{new_serial})).errno());
+ try testing.expectEqual(first_serial, layout.columnSerial(p, 0));
+}
+
+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);
+ const serial = layout.columnSerial(p, 0);
+ var name: [16]u8 = undefined;
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, col_dir, try std.fmt.bufPrint(&name, "{d}", .{serial})).reply.status);
+ try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "0").errno());
+ try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "777").errno());
+ const tag = Node.ofCol(serial, .tag);
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(serial, .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));
+}
+
+test "a focus write makes its pane's column the active one: layout says so and pane/new goes there" {
+ for ([_][]const u8{ "Placement acme", "Placement pardes" }) |placement| {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ try testing.expect(p.executeBuiltinLine(p.active, placement));
+ const left = p.panes[0].?.serial;
+ p.newColumn(p.active);
+ p.newScratchFor(p.active, .fs); // fills the new, right column
+ p.sync();
+ try testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.col);
+ var digits: [16]u8 = undefined;
+ _ = th.wr(p, @intFromEnum(tree.TopFile.focus), try std.fmt.bufPrint(&digits, "{d}\n", .{left}));
+ var want: [32]u8 = undefined;
+ const text = th.rd(p, @intFromEnum(tree.TopFile.layout), 0, 4096).bytes;
+ try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "active {d}\n", .{layout.columnSerial(p, 0)})));
+ const made = th.call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(tree.TopFile.new) }).reply.handle;
+ try testing.expectEqual(@as(usize, 0), layout.findPane(p, p.paneBySerial(made).?).?.col);
+ }
+}
+
+test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const first = layout.columnSerial(p, 0);
+ p.newColumn(p.active);
+ const empty = layout.columnSerial(p, 1);
+ // New on the empty column's ctl fills it, wherever the keyboard is.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .ctl), "New\n").reply.status);
+ try testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
+ // rmdir refuses a column with a pane, and says why.
+ const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
+ try testing.expectEqual(E.NOTEMPTY, full.errno());
+ try testing.expect(th.logHas(p, "column not empty"));
+ // exec runs a word as a click in that column's tag: Delcol closes it.
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
+ try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
+ // An empty column goes on rmdir.
+ p.newColumn(p.active);
+ const made = layout.columnSerial(p, 1);
+ try testing.expectEqual(tree.Status.ok, th.call(p, .{ .tag = 2, .op = .release, .node = Node.ofCol(made, .dir), .remove = true }).reply.status);
+ try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, made));
+ try testing.expectEqual(first, layout.columnSerial(p, 0));
+ // Joincol folds a column into the one on its right, which keeps its own
+ // serial and tag.
+ p.newColumn(p.active);
+ const right = layout.columnSerial(p, 1);
+ _ = th.wr(p, Node.ofCol(right, .tag), " Mine");
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(first, .ctl), "Joincol\n").reply.status);
+ try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, first));
+ try testing.expectEqual(@as(?usize, 0), layout.columnBySerial(p, right));
+ try testing.expect(std.mem.endsWith(u8, headerText(p, right).?, " Mine"));
+ // The root's ctl points a column word at the column's own.
+ try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col/<serial>/ctl"));
+}
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index b4144fd2..2fdc47cc 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -51,7 +51,7 @@ fn noteResult(p: *Pardes, serial: u32) void {
/// Runs one look or exec from the pane `id`, then records the panes it
/// created; failing that, a look records the pane it focused and an exec
/// the pane it acted on (even one it closed).
-fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
+pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const context = p.panes[id].?.serial;
// A pane an event reader holds hears its clicks instead of acting on
// them (editors/acme/look.c:53 and exec.c:173 send them to winevent),
@@ -138,7 +138,14 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
if (!apply) continue;
const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
+ p.fs.no_pane_slot = false;
run(p, id, exec, text);
+ // A pane it would have opened had no slot: the write fails, and
+ // a look reads back nothing.
+ if (p.fs.no_pane_slot) {
+ p.fs.results_len = 0;
+ return tree.failText(req.tag, E.IO, p.fs.ename[0..p.fs.no_pane_slot_len]);
+ }
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -259,10 +266,23 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const sp = std.mem.indexOfAny(u8, line, " \t");
const word = line[0 .. sp orelse line.len];
const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
- const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line);
+ const b = std.meta.stringToEnum(Builtin, word) orelse {
+ // A setting this build's frontend cannot show (Lift, GripWidth on a
+ // terminal) is known, and says so rather than sound misspelt.
+ if (config.Runtime.find(word) != null) {
+ var why: [64]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line);
+ }
+ return refuse(p, req, "unknown control message", line);
+ };
// Edit is the one pane word the root takes too: at the active pane.
if (builtins.registry.scope(b) != scope and b != .Edit)
- return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl");
+ return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane)
+ "/ctl"
+ else if (b == .Delcol or b == .Joincol)
+ "col/<serial>/ctl" // a column's own ctl, the one /layout names
+ else
+ "pane/<n>/ctl");
if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
const setting = config.Runtime.find(word) orelse return null;
@@ -281,14 +301,19 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
break :probe probe.apply(setting, if (arg.len > 0) arg else null);
},
};
- return if (takes) null else refuse(p, req, "bad value in control message", line);
+ if (takes) return null;
+ var why: [104]u8 = undefined;
+ return refuse(p, req, if (config.Runtime.choices(setting.action)) |values|
+ std.fmt.bufPrint(&why, "bad value in control message; takes {s}", .{values}) catch "bad value in control message"
+ else
+ "bad value in control message", line);
}
/// Runs a checked control message at pane `id`, as a click on its word
/// would. Answers the refusal when it would have opened a prompt for an
/// argument, which nobody writing to a file is there to type, or when it
/// reported an error: `Mount: AlreadyMounted "Mount peer /tmp/s"`.
-fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
+pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
p.fs.no_prompt = true;
p.fs.refused = false;
p.fs.failure_len = 0;
@@ -307,7 +332,10 @@ fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
if (!created) noteResult(p, context);
if (p.fs.refused) return refuse(p, req, e_prompt, line);
if (p.fs.failure_len == 0) return null;
- const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
+ // An Edit's failure names what failed (the pattern that missed): its
+ // line, a program, is not quoted after it.
+ const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit");
+ const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename };
}
@@ -320,13 +348,22 @@ const e_prompt = "control message needs its argument";
/// What is not a setting -- platform, themes, where the config came from --
/// is /status's or the Config window's.
fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
- const s = &p.settings;
+ try writeSettingsOf(p, &p.settings, w);
+ try w.writeAll("LocationsConfig ");
+ try p.locations_config.write(w);
+ try w.writeByte('\n');
+ if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(p.settings.pet)});
+}
+
+/// The setting words of `s`, one a line, as the root ctl reads them; a Dump
+/// keeps those that differ from a fresh session's (dump.zig).
+pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer) !void {
inline for (config.Runtime.settings) |setting| {
if (comptime !setting.enabled(builtins.capabilities)) continue;
const word = setting.word;
switch (setting.action) {
.toggle => |field| try w.print("{s} {s}\n", .{ word, if (@field(s, @tagName(field))) "on" else "off" }),
- .theme => try w.print("{s} {s}\n", .{ word, p.theme().name }),
+ .theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }),
.shell => {
const chosen = s.shell.requested.get();
try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
@@ -347,8 +384,12 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }),
},
.message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }),
- // Bare, it names the default directory.
- .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
+ // The directory in effect, as Config reports it; bare only when
+ // there is none (no home to put the default in).
+ .dump_dir => {
+ var buf: [900]u8 = undefined;
+ if (pardes.dump.directory(&buf, s.dump_dir.get())) |dir| try w.print("{s} {s}\n", .{ word, dir }) else try w.print("{s}\n", .{word});
+ },
.transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
.scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }),
.shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }),
@@ -359,10 +400,6 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
.grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
}
}
- try w.writeAll("LocationsConfig ");
- try p.locations_config.write(w);
- try w.writeByte('\n');
- if (comptime builtins.Pet.enabled) try w.print("Pet {s}\n", .{@tagName(s.pet)});
}
pub fn rootLen(p: *Pardes) u64 {
@@ -407,11 +444,20 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
/// itself, so a new builtin is listed the moment it exists.
fn writeCommands(w: *std.Io.Writer) !void {
for (std.enums.values(Builtin)) |b| {
- try w.print("{s}{s} {s}\n", .{
+ try w.print("{s}{s} {s}", .{
@tagName(b),
if (builtins.registry.takesArg(b)) " arg" else "",
if (builtins.registry.scope(b) == .session) "root" else "pane",
});
+ // A setting that chooses among words: the words, comma-joined. Bare,
+ // it steps to the next of them (a two-valued one flips).
+ if (config.Runtime.find(@tagName(b))) |setting| if (config.Runtime.choices(setting.action)) |values| {
+ try w.writeByte(' ');
+ var parts = std.mem.splitSequence(u8, values, ", ");
+ var first = true;
+ while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v });
+ };
+ try w.writeByte('\n');
}
}
@@ -448,9 +494,12 @@ 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.
+ // The keyboard goes to the pane itself, off any header that had it, and
+ // its column becomes the active one, as the user's click there would
+ // make it: pane/new and a look place there next, whatever Placement.
tagline.exitHeader(p);
p.active = id;
+ if (pardes.layout.findPane(p, id)) |at| p.setActiveColumn(at.col);
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -516,10 +565,15 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
} else if (std.mem.startsWith(u8, line, "answer ")) {
// The pane's question (its `ask` in the log), answered.
const choice = std.mem.trim(u8, line["answer ".len..], " ");
- if (choice.len != 1) return refuse(p, req, "answer takes one choice, or -", line);
const id = p.paneBySerial(serial).?;
- if (p.panes[id].?.prompt != .del_side and p.panes[id].?.prompt != .repl_choice)
- return refuse(p, req, "no question asked", line);
+ var offered: [64]u8 = undefined;
+ const choices = p.questionChoices(id, &offered) orelse return refuse(p, req, "no question asked", line);
+ // Only a choice the question offers answers it; anything
+ // else leaves it standing, and says what it takes.
+ if (!std.mem.eql(u8, choice, "-") and (choice.len != 1 or std.mem.indexOfScalar(u8, choices, choice[0]) == null)) {
+ var why: [96]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, "answer takes {s} or -", .{choices}) catch "answer takes a choice or -", line);
+ }
if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]);
} else if (!apply) {
if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
@@ -618,10 +672,11 @@ test "commands lists every builtin with its argument and the ctl that takes it"
const b = std.meta.stringToEnum(Builtin, words.next().?).?;
if (builtins.registry.takesArg(b)) try testing.expectEqualStrings("arg", words.next().?);
try testing.expectEqualStrings(if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?);
+ if (config.Runtime.find(@tagName(b))) |s| if (config.Runtime.choices(s.action) != null) try testing.expect(words.next() != null);
try testing.expect(words.next() == null);
}
try testing.expectEqual(std.enums.values(Builtin).len, lines);
- for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
+ for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}
@@ -747,7 +802,8 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("not a session control message \"Del\": write it to pane/<n>/ctl", wr(p, root_ctl, "Del").reply.ename);
try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
- try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
+ try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
+ try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\""));
@@ -1216,3 +1272,47 @@ test "a look after Newcol fills the empty column the keyboard is on" {
try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]);
try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]);
}
+
+test "a Restore of a file that is no dump fails the write before any warning, and a failed Dump fails its write" {
+ if (comptime !pardes.hosted) return error.SkipZigTest;
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const f = pane_files.fileOf(p.panes[0].?).?;
+ f.content = try p.gpa.realloc(f.content, 3);
+ @memcpy(f.content, "yy\n");
+ f.revision +%= 1; // unsaved: a Restore would warn about it first
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "notes.txt", .data = "just notes\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line_buf: [4200]u8 = undefined;
+ const line = try std.fmt.bufPrint(&line_buf, "Restore {s}/notes.txt\n", .{dir});
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const refused = wr(p, root_ctl, line);
+ try testing.expectEqual(E.IO, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "not a pardes dump") != null);
+ try testing.expect(p.restore_req == null);
+ try testing.expect(p.panes[0].?.discard_warned == null);
+
+ p.fs.late_failure_len = 0;
+ p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: FileNotFound", p.fs.late_failure[0..p.fs.late_failure_len]);
+}
+
+test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ if (!builtins.capabilities.lift) {
+ try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
+ try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
+ }
+ try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename);
+ var buf: [900]u8 = undefined;
+ if (pardes.dump.directory(&buf, "")) |dir| {
+ var want: [920]u8 = undefined;
+ const line = try std.fmt.bufPrint(&want, "\nDumpDir {s}\n", .{dir});
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null);
+ }
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 0e489be7..ccc61352 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.serial}) catch "col";
+ },
.pane => |pt| {
serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-";
file = switch (pt.file) {
@@ -940,7 +944,9 @@ test "a Dump written and a Restore made are in the log, with their files" {
const log = try freezeLog(restored);
defer restored.gpa.free(log.bytes);
var want: [64]u8 = undefined;
- try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })));
+ try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })) != null);
+ // ...and each column's: restoredcol <old> <new>.
+ try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restoredcol {d} {d}\n", .{ pardes.layout.columnSerial(p, 0), pardes.layout.columnSerial(restored, 0) })));
}
test "opens of the log share the open records, and a closed one frees its record" {
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 13381066..c594c7a3 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -63,6 +63,11 @@ pub const State = struct {
lock: ?u32 = null,
/// The host started a shell it could not teach to mark its prompts.
unmarked: bool = false,
+ /// `tag` was truncated (`echo X > tag`): until that open is released,
+ /// the newline that ends a write is held back, so the one that ends the
+ /// whole of it is dropped and draws no empty row.
+ tag_rewrite: bool = false,
+ tag_held_newline: bool = false,
/// The pty's size as pty/ctl's winsize last set it, until the pane's own
/// grid resizes and gives the pty its size again.
winsize: ?[2]u16 = null,
@@ -260,6 +265,13 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
// ---- the index ----
+/// The serial of the column a pane is in, as /layout and /col name it; the
+/// last word of its /index line.
+pub fn columnOf(p: *Pardes, id: usize) u32 {
+ const at = pardes.layout.findPane(p, id) orelse return 0;
+ return pardes.layout.columnSerial(p, at.col);
+}
+
pub fn indexLen(p: *Pardes) u64 {
var n: u64 = 0;
var last: u32 = 0;
@@ -269,6 +281,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;
}
@@ -427,11 +440,23 @@ fn writeTag(req: Req, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
// The tag's own text grows by what is written, newlines and all: a tag
// is a text like any other (acme's tag file appends the same way).
+ const pf = &pane.fs;
const had = tagline.curTail(pane).len;
- const room = limits.max_tag_tail -| had;
+ const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
if (room == 0) return Reply.fail(req.tag, E.NOSPC);
const take = wholeUtf8(req.data[0..@min(req.data.len, room)]);
- pane.appendTag(req.data[0..take]) catch return Reply.fail(req.tag, E.NOMEM);
+ // A truncating write drops ONE trailing newline, its whole text's: a
+ // newline held from a write before goes in once more text follows it.
+ if (pf.tag_held_newline) {
+ pane.appendTag("\n") catch return Reply.fail(req.tag, E.NOMEM);
+ pf.tag_held_newline = false;
+ }
+ var text = req.data[0..take];
+ if (pf.tag_rewrite and text.len > 0 and text[text.len - 1] == '\n') {
+ text = text[0 .. text.len - 1];
+ pf.tag_held_newline = true;
+ }
+ pane.appendTag(text) catch return Reply.fail(req.tag, E.NOMEM);
return .{ .tag = req.tag, .written = @intCast(take) };
}
@@ -544,7 +569,10 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const name = std.mem.trim(u8, req.data, " \t\r\n");
if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
- if (fileOf(pane) == null) return Reply.fail(req.tag, E.PERM);
+ if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal())
+ "rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
+ else
+ "rename not allowed: an image or PDF is named by the file it shows");
const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
return Reply.fail(req.tag, E.NOMEM);
if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return Reply.fail(req.tag, E.INVAL);
@@ -648,6 +676,8 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0;
pane.tag.vsel.active = false;
pane.tag.nsel = 0;
+ pf.tag_rewrite = true;
+ pf.tag_held_newline = false;
},
// A shell's `>` truncates before it writes: the address written
// is evaluated from where the last one left off, as with `>>`.
@@ -681,7 +711,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 {d}\n", .{ pane.serial, columnOf(p, 0) });
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 });
@@ -690,7 +720,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 {d}\n", .{ pane.serial, columnOf(p, 0) });
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);
@@ -700,7 +730,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" {
@@ -712,10 +742,12 @@ 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 {d}\n", .{ pane.serial, columnOf(p, 0) }),
) != null);
try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes);
- try testing.expectEqual(E.PERM, wr(p, Node.of(pane.serial, .name), "/elsewhere\n").errno());
+ const refused = wr(p, Node.of(pane.serial, .name), "/elsewhere\n");
+ try testing.expectEqual(E.PERM, refused.errno());
+ try testing.expect(std.mem.startsWith(u8, refused.reply.ename, "rename not allowed: a terminal is named"));
}
test "body reads at any offset and writes append" {
@@ -977,6 +1009,19 @@ test "truncating the tag clears its editable tail" {
// and a click on the G puts the tag's cursor on it
const at = tagline.tagOffsetAt(p, pane, 0, "/test.txt ".len).?;
try testing.expectEqualStrings("Get", whole[at.col..]);
+
+ // `echo Make > tag`: the newline ending the truncating write's text is
+ // dropped, one only; a newline between its writes stays.
+ _ = call(p, .{ .tag = 3, .op = .release, .node = tag });
+ _ = call(p, .{ .tag = 4, .op = .setattr, .node = tag, .truncate = true });
+ _ = wr(p, tag, "Make\n");
+ try testing.expectEqualStrings("Make", pane.tag.own.?);
+ _ = wr(p, tag, "More\n\n");
+ try testing.expectEqualStrings("Make\nMore\n", pane.tag.own.?);
+ _ = call(p, .{ .tag = 5, .op = .release, .node = tag });
+ // `>>` keeps what it is given.
+ _ = wr(p, tag, " Tail\n");
+ try testing.expectEqualStrings("Make\nMore\n Tail\n", pane.tag.own.?);
}
test "name reads the file name and writing it promotes a scratch without touching its body" {
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index 462ea40f..f496da03 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -146,7 +146,7 @@ test "terminal body handles keep one history snapshot across fragmented reads" {
try testing.expectEqualStrings("", call(p, .{ .tag = 4, .op = .read, .node = node, .handle = opened.reply.handle, .off = original.len, .size = 3 }).bytes);
_ = call(p, .{ .tag = 5, .op = .release, .node = node, .handle = opened.reply.handle });
const newer = call(p, .{ .tag = 6, .op = .open, .node = node });
- try testing.expectEqualStrings("new output", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ try testing.expectEqualStrings("new output\n", call(p, .{ .tag = 7, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
_ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = newer.reply.handle });
}
@@ -162,7 +162,7 @@ test "an empty terminal body snapshot stays empty while output continues" {
try testing.expectEqualStrings("", call(p, .{ .tag = 3, .op = .read, .node = node, .handle = opened.reply.handle, .size = 32 }).bytes);
_ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = opened.reply.handle });
const newer = call(p, .{ .tag = 5, .op = .open, .node = node });
- try testing.expectEqualStrings("new output", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
+ try testing.expectEqualStrings("new output\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = newer.reply.handle, .size = 32 }).bytes);
_ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = newer.reply.handle });
}
@@ -194,13 +194,13 @@ test "terminal body snapshots are lazy bounded and released after the pane close
try testing.expectEqual(E.NOMEM, failed.errno());
try testing.expect(p.fs.opens[handles[0] - 1].what.snapshot == null);
const first = call(p, .{ .tag = 4, .op = .read, .node = node, .handle = handles[0], .size = 32 });
- try testing.expectEqualStrings("latest output", first.bytes);
+ try testing.expectEqualStrings("latest output\n", first.bytes);
const saved = p.fs.opens[handles[0] - 1].what.snapshot.?;
failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 });
p.gpa = failing.allocator();
const second = call(p, .{ .tag = 5, .op = .read, .node = node, .handle = handles[0], .off = 7, .size = 32 });
p.gpa = gpa;
- try testing.expectEqualStrings("output", second.bytes);
+ try testing.expectEqualStrings("output\n", second.bytes);
try testing.expect(!failing.has_induced_failure);
try testing.expectEqual(saved.ptr, p.fs.opens[handles[0] - 1].what.snapshot.?.ptr);
try testing.expectEqual(E.INVAL, call(p, .{ .tag = 6, .op = .read, .node = screen_node, .handle = handles[0], .size = 32 }).errno());
@@ -328,3 +328,17 @@ test "screen inspection preserves acknowledged presentation and the next real fr
}
}
}
+
+test "a terminal's body is its logical lines: a row the terminal wrapped is joined again" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const long = "w" ** 50 ++ "x" ** 150; // wider than the pane: it wraps
+ try testing.expect(long.len > pane.cols);
+ p.update(.{ .output = .{ .pane = 0, .bytes = long ++ "\r\nnext" } });
+ while (p.nextEffect()) |_| {}
+ const node = Node.of(pane.serial, .body);
+ const body = th.rd(p, node, 0, 4096).bytes;
+ try testing.expectEqualStrings(long ++ "\nnext\n", body);
+ try testing.expectEqual(@as(u64, body.len), th.call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size);
+}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index ad95f6bf..baf8c772 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -18,6 +18,8 @@ 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");
+const layout = pardes.layout;
pub const pty = @import("pty.zig");
pub const sources = @import("sources.zig");
pub const screen = @import("screen.zig");
@@ -68,9 +70,11 @@ pub const e_shut_down = "window shut down";
/// Tremove closes a pane; nothing else in the tree is created or destroyed by
/// the protocol, and wstat stays a truncation. Making a pane is an open of
-/// /pane/new, which needs no feature of the engine's, so Tcreate is refused
-/// everywhere, as it is in acme (editors/acme/fsys.c, fsyscreate).
-pub const features: cloud9.fs.Features = .{ .remove = true };
+/// /pane/new, so Tcreate is refused everywhere, as it is in acme
+/// (editors/acme/fsys.c, fsyscreate) -- here rather than by the engine, so
+/// a create in a pane that has closed says the pane is gone (a shell's `>`
+/// to a closed pane's file creates when its lookup fails).
+pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// Whether a request must wait until no step is out in a syscall
/// (`pardes.turn`): what would change a pane such a step still points into.
@@ -169,7 +173,7 @@ pub fn openOf(p: *Pardes, req: Req) ?*Open {
// ---- nodes ----
-pub const TopFile = enum(u4) {
+pub const TopFile = enum(u5) {
root = 1,
README,
index,
@@ -184,6 +188,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);
@@ -191,14 +198,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;
}
};
@@ -262,6 +269,21 @@ pub const PaneFile = enum(u5) {
}
};
+/// /col/<serial> and its tag: a column is named by its serial, which stays
+/// while it lives as a pane's does; this bit, past a pane serial's 32, keeps
+/// the two apart.
+pub const ColFile = enum(u5) {
+ dir = 0,
+ tag = 1,
+ ctl = 2,
+ exec = 3,
+
+ pub fn fileName(f: ColFile) []const u8 {
+ return @tagName(f);
+ }
+};
+const col_bit: u59 = 1 << 58;
+
pub const Node = packed struct(u64) {
file: u5 = 0,
serial: u59 = 0,
@@ -271,11 +293,19 @@ pub const Node = packed struct(u64) {
return @bitCast(Node{ .file = @intFromEnum(file), .serial = serial });
}
+ pub fn ofCol(serial: u32, file: ColFile) u64 {
+ return @bitCast(Node{ .file = @intFromEnum(file), .serial = col_bit | @as(u59, serial) });
+ }
+
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 = .{
+ .serial = 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,
@@ -286,6 +316,7 @@ pub const Node = packed struct(u64) {
pub const Target = union(enum) {
top: TopFile,
pane: struct { serial: u32, file: PaneFile },
+ col: struct { serial: u32, file: ColFile },
};
/// Opening /pane/new makes a pane and reading the open fid answers its
@@ -371,6 +402,11 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
// ---- dispatch ----
pub fn handle(p: *Pardes, req: Req) Reply {
+ if (req.op == .open and req.create) {
+ if (Node.target(req.node)) |t| if (t == .pane and p.paneBySerial(t.pane.serial) == null)
+ return Reply.fail(req.tag, E.NOENT);
+ return Reply.fail(req.tag, E.PERM);
+ }
const host = req.node == fs.os_root or req.node & fs.os_node != 0;
const archive = req.node & sources.archive_node != 0;
// Only a pane directory is removed. The host tree and the embedded
@@ -381,7 +417,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),
@@ -403,7 +439,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| {
@@ -422,6 +458,23 @@ 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 (layout.columnBySerial(p, c.serial) == null) return null;
+ return .{
+ .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.serial}) catch unreachable) else c.file.fileName(),
+ .node = Node.ofCol(c.serial, c.file),
+ .dir = c.file == .dir,
+ .mode = switch (c.file) {
+ .dir => 0o755,
+ .ctl, .exec => 0o222,
+ .tag => 0o666,
+ },
+ .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0,
+ .mtime = p.fs.started,
+ .append = c.file == .tag,
+ };
},
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return null;
@@ -454,7 +507,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),
@@ -485,6 +540,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 });
}
@@ -507,8 +563,22 @@ 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 serial, in decimal digits and never 0.
+ .col => col: {
+ if (name.len > 10) return Reply.fail(req.tag, E.NOENT);
+ for (name) |c| if (c < '0' or c > '9') return Reply.fail(req.tag, E.NOENT);
+ const serial = std.fmt.parseInt(u32, name, 10) catch return Reply.fail(req.tag, E.NOENT);
+ if (serial == 0) return Reply.fail(req.tag, E.NOENT);
+ break :col Node.ofCol(serial, .dir);
+ },
else => return Reply.fail(req.tag, E.NOTDIR),
},
+ .col => |c| col: {
+ if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR);
+ const f = std.meta.stringToEnum(ColFile, name) orelse return Reply.fail(req.tag, E.NOENT);
+ if (f == .dir) return Reply.fail(req.tag, E.NOENT);
+ break :col Node.ofCol(c.serial, f);
+ },
.pane => |t| pane: {
_ = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const f = switch (t.file) {
@@ -548,7 +618,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");
@@ -568,8 +638,24 @@ 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;
+ const serial = layout.columnSerial(p, c);
+ stageDirent(out, p.gpa, Node.ofCol(serial, .dir), true, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable);
+ },
else => return Reply.fail(req.tag, E.NOTDIR),
},
+ .col => |c| {
+ if (layout.columnBySerial(p, c.serial) == null) return Reply.fail(req.tag, E.NOENT);
+ if (c.file != .dir) return Reply.fail(req.tag, E.NOTDIR);
+ inline for (.{ ColFile.tag, ColFile.ctl, ColFile.exec }) |f| {
+ if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, Node.ofCol(c.serial, f), false, f.fileName());
+ }
+ },
.pane => |t| {
const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT);
const terminal = p.panes[id].?.isTerminal();
@@ -594,7 +680,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// pane it made, so reading it twice answers the same one and
// closing it leaves the pane alone.
.new => {
- const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE);
+ const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots");
p.newScratchFor(p.active, .fs);
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
return .{ .tag = req.tag, .handle = made.serial };
@@ -603,6 +689,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
.log => .{ .log = .{} },
else => return .{ .tag = req.tag },
},
+ .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .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);
@@ -659,6 +746,7 @@ fn remove(p: *Pardes, req: Req) Reply {
const t = switch (target) {
.pane => |t| t,
.top => return Reply.fail(req.tag, E.PERM),
+ .col => |c| return cols.remove(p, req, c.serial, c.file),
};
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);
@@ -676,8 +764,16 @@ fn release(p: *Pardes, req: Req) Reply {
/// its releases before the connection's slot is reused, so none outlives
/// the connection it came on.
fn releaseHandle(p: *Pardes, req: Req) void {
- const o = openOf(p, req) orelse return;
const t = Node.target(req.node).?;
+ // A truncating write to `tag` is over when its open goes (pane.zig
+ // writeTag): a newline it held back is dropped.
+ if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| {
+ 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.serial);
+ 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) {
.event => |reader| {
@@ -712,7 +808,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.serial) != .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) };
}
@@ -757,8 +854,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);
},
@@ -770,6 +868,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: {
@@ -778,7 +883,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.
@@ -794,6 +899,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.serial) else Reply.fail(req.tag, E.PERM),
}
}
@@ -804,6 +910,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| {
@@ -812,6 +919,12 @@ 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 switch (c.file) {
+ .tag => cols.write(p, req, c.serial),
+ .ctl => cols.writeCtl(p, req, c.serial),
+ .exec => cols.writeExec(p, req, c.serial),
+ .dir => Reply.fail(req.tag, E.PERM),
+ },
}
}
@@ -823,6 +936,7 @@ test {
_ = addr;
_ = ctl;
_ = pty;
+ _ = cols;
_ = sources;
_ = screen;
}
@@ -955,12 +1069,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);
@@ -1099,10 +1214,11 @@ test "opening /pane/new makes a pane and removing one closes it" {
try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content);
- // The tree declares no create, so the engine refuses every Tcreate
- // (cloud9 fs.zig: `.tcreate => if (features.create) ... else fail(e_perm)`),
- // and `new` is the only name in /pane that is not a serial.
- try testing.expect(!features.create);
+ // Every create is refused, a pane's that has closed as gone, and `new`
+ // is the only name in /pane that is not a serial.
+ try testing.expectEqual(E.PERM, call(p, .{ .tag = 6, .op = .open, .node = panes_dir, .create = true, .data = "scratch" }).errno());
+ try testing.expectEqual(E.PERM, call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .dir), .create = true, .data = "x" }).errno());
+ try testing.expectEqual(E.NOENT, call(p, .{ .tag = 6, .op = .open, .node = Node.of(999_999, .dir), .create = true, .data = "exec" }).errno());
try testing.expectEqual(E.NOENT, look_up(p, panes_dir, "scratch").errno());
try testing.expectEqual(E.NOENT, look_up(p, root, new_pane).errno());
// ...and it is listed, because the user asked to see it in `ls`.
@@ -1190,3 +1306,32 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expect(resolveSelf(p, "pane/new/body") == null);
try testing.expectEqual(before, p.next_serial);
}
+
+test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
+ var want: [64]u8 = undefined;
+ const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES});
+ const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
+ try testing.expectEqual(E.NFILE, refused.errno());
+ try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
+ try testing.expect(th.logHas(p, said));
+ // A look that would open a pane fails its write, and says the same.
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ const look_node = @intFromEnum(TopFile.look);
+ const looked = th.wr(p, look_node, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir}));
+ try testing.expect(looked.errno() != 0);
+ try testing.expect(std.mem.indexOf(u8, looked.reply.ename, said) != null);
+ try testing.expectEqualStrings("", th.rd(p, look_node, 0, 64).bytes);
+ // New from a pane's ctl fails as well.
+ const ctl_node = Node.of(p.panes[0].?.serial, .ctl);
+ const made = th.wr(p, ctl_node, "New\n");
+ try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
+}