diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/cols.zig | 284 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 142 | ||||
| -rw-r--r-- | src/ninep/events.zig | 8 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 61 | ||||
| -rw-r--r-- | src/ninep/screen.zig | 22 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 197 |
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); +} |
