//! The layout and the header tags: /layout, /tag (the workspace's) and //! /col//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/ 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 `: 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 }; if (pardes.ctlfs.pane.tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why); 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. What /// they say is the session's in the log (`msg -`), not the focused pane's. pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { // What this write touched, alone, reads back (runBuiltin notes it). p.fs.results_len = 0; p.fs.session_write = true; defer p.fs.session_write = false; 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; // Plan 9's words for it, so a mount's errno is EINVAL as the root's // and a pane's ctl give (9ns reads "control message"). if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{line[0..@min(line.len, 64)]}) catch "unknown control message"); // Only Tty takes a word (its shell); `New extra` is no New. if (!std.mem.eql(u8, word, "Tty") and word.len != line.len) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{line[0..@min(line.len, 64)]}) catch "wrong #args in control message"); const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT); p.exec_column = col; defer p.exec_column = null; p.exec_header = true; defer p.exec_header = false; 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 (`serial`), or the workspace tag's (/tagexec, null): /// each line run as a middle click on it in that tag, a column's acting on /// that column. A read answers the panes the last write touched, as /exec's /// does; what the words say is the session's in the log (`msg -`). pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { p.fs.results_len = 0; p.fs.session_write = true; defer p.fs.session_write = false; 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') or c == 0x7f) return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control); if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal; // A pane's word (Undo, Msg, Save) is no word of a tag no pane owns: // refused as the root's ctl refuses it, never done at whichever // pane has the keyboard. The tag's own words (New, Tty, Find, Grep // in a column's) are its to run. if (paneWord(line, if (serial == null) pardes.config.topbar_str else tagline.column_tag_default)) return pardes.ctlfs.ctl.refuseTo(p, req, "not a session control message", line, "pane//ctl"); const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null; if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); p.exec_column = col; defer p.exec_column = null; p.exec_header = true; defer p.exec_header = false; if (pardes.ctlfs.ctl.captured(p, req, p.active, true, line, true)) |refusal| return refusal; } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } test "New written to a column's exec makes its scratch in the session's directory, as tagexec's does" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const session = "/project/src"; @memcpy(p.session_dir[0..session.len], session); p.session_dir_len = session.len; const serial = layout.columnSerial(p, 0); const exec_node = tree.Node.ofCol(serial, .exec); try testing.expectEqual(tree.Status.ok, th.wr(p, exec_node, "New\n").reply.status); try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path); } test "New written to a column's ctl reads back the new pane alone, as its exec does" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const root_exec = @intFromEnum(tree.TopFile.exec); _ = th.wr(p, root_exec, "Msg before\n"); const ctl_node = tree.Node.ofCol(layout.columnSerial(p, 0), .ctl); try testing.expectEqual(tree.Status.ok, th.wr(p, ctl_node, "New\n").reply.status); var want: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{p.panes[p.active].?.serial}), th.rd(p, root_exec, 0, 64).bytes); } /// Whether `line` names a builtin that acts on a pane (every one of pane /// scope, Edit too, which the root's ctl takes at the active pane) and is /// not one of `own`, the words its tag holds by default: those a tag no /// pane owns refuses, never doing them at the pane with the keyboard. fn paneWord(line: []const u8, own: []const u8) bool { const cmd = pardes.exec.commandText(line); const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len]; const b = std.meta.stringToEnum(pardes.builtins.registry.Builtin(), word) orelse return false; if (pardes.builtins.registry.scope(b) == .session) return false; var words = std.mem.tokenizeScalar(u8, own, ' '); while (words.next()) |w| if (std.mem.eql(u8, w, word)) return false; return true; } test "a pane's word written to /tagexec or a column's exec is refused, and acts on no pane" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); for ([_]u64{ @intFromEnum(tree.TopFile.tagexec), col_exec }) |node| { for ([_][]const u8{ "Undo\n", "Msg hi\n", "Redo\n" }) |line| { const refused = th.wr(p, node, line); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "not a session control message"); } } // The column tag's own words still run there. try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status); } test "every pane word a tag no pane owns does not hold is refused there, Edit included, and the pane is untouched" { const p = try th.withFile(testing.allocator, "keep me\n"); defer p.deinit(); const Builtin = pardes.builtins.registry.Builtin(); const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); for ([_]struct { node: u64, own: []const u8 }{ .{ .node = @intFromEnum(tree.TopFile.tagexec), .own = pardes.config.topbar_str }, .{ .node = col_exec, .own = tagline.column_tag_default }, }) |tag_file| { var checked: usize = 0; for (std.enums.values(Builtin)) |b| { if (pardes.builtins.registry.scope(b) == .session) continue; var owned = false; var words = std.mem.tokenizeScalar(u8, tag_file.own, ' '); while (words.next()) |w| owned = owned or std.mem.eql(u8, w, @tagName(b)); if (owned) continue; var line: [64]u8 = undefined; const text = try std.fmt.bufPrint(&line, "{s}{s}\n", .{ @tagName(b), if (b == .Edit) " ,d" else if (pardes.builtins.registry.requiresArg(b)) " x" else "" }); const refused = th.wr(p, tag_file.node, text); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectStringStartsWith(refused.reply.ename, "not a session control message"); checked += 1; } try testing.expect(checked > 20); } // `Edit ,d` wiped the pane with the keyboard: nothing touched it. try testing.expectEqualStrings("keep me\n", p.panes[0].?.file.?.content); } /// 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()); var rec: [64]u8 = undefined; try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty}))); // 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, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{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//ctl")); } test "tagexec runs a word as a click in the workspace tag; exec files read back what they touched; their messages are the session's" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const tagexec = @intFromEnum(tree.TopFile.tagexec); try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status); try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status); try testing.expectEqual(@as(usize, 2), p.ncol); // Its announcement is the message row's, never logged; a pane's word // such as Msg is refused there. try testing.expect(!th.logHas(p, "msg - Newcol\n")); try testing.expectEqual(E.INVAL, th.wr(p, tagexec, "Msg from the top\n").errno()); // A column's exec makes a pane there and reads it back. const right = layout.columnSerial(p, 1); try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status); const made = p.panes[p.col_panes[1][0]].?.serial; var want: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes); try testing.expect(!th.logHas(p, "msg - New\n")); // announced on the message row only } test "a word run from the workspace's or a column's tag runs in the session's directory, a pane's in its own" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const session = p.sessionDir(); try testing.expect(!std.mem.eql(u8, session, pardes.Pardes.paneDir(p.panes[0].?))); // the file is elsewhere const dirs = [_]u64{ @intFromEnum(tree.TopFile.tagexec), Node.ofCol(layout.columnSerial(p, 0), .exec) }; for (dirs) |node| { try testing.expectEqual(tree.Status.ok, th.wr(p, node, "true\n").reply.status); const made = p.paneBySerial(p.fs.results[0]).?; try testing.expectEqualStrings(session, pardes.Pardes.paneDir(p.panes[made].?)); try p.removePane(made, null); } const from_pane = p.panes[0].?.serial; _ = th.wr(p, Node.of(from_pane, .exec), "true\n"); const made = p.paneBySerial(p.fs.results[0]).?; try testing.expectEqualStrings(pardes.Pardes.paneDir(p.panes[0].?), pardes.Pardes.paneDir(p.panes[made].?)); } test "a command run from a column's tag gets its pane in that column, not the last" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); p.newColumn(p.active); p.newScratchFor(p.active, .fs); // fills the new, right column p.sync(); const left = layout.columnSerial(p, 0); try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(left, .exec), "true\n").reply.status); const made = p.paneBySerial(p.fs.results[0]).?; try testing.expectEqual(@as(usize, 0), layout.findPane(p, made).?.col); // Finished, it is reused only by its own column's commands. p.panes[made].?.command_done = true; p.panes[made].?.command_pty = false; try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(layout.columnSerial(p, 1), .exec), "true\n").reply.status); const other = p.paneBySerial(p.fs.results[0]).?; try testing.expect(other != made); try testing.expectEqual(@as(usize, 1), layout.findPane(p, other).?.col); } test "a column's ctl refuses an unknown word and an argument as the other ctls do" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); const ctl_node = Node.ofCol(layout.columnSerial(p, 0), .ctl); const unknown = th.wr(p, ctl_node, "Bogus\n"); try testing.expectEqual(E.INVAL, unknown.errno()); try testing.expect(std.mem.startsWith(u8, unknown.reply.ename, "unknown control message \"Bogus\"")); const extra = th.wr(p, ctl_node, "New extra\n"); try testing.expectEqual(E.INVAL, extra.errno()); try testing.expect(std.mem.startsWith(u8, extra.reply.ename, "wrong #args")); try testing.expectEqual(@as(usize, 1), p.col_n[0]); // A pane's bare answer and a winsize of no size say what they take. try testing.expectEqualStrings("answer takes a choice or -", th.wr(p, Node.of(p.panes[0].?.serial, .ctl), "answer\n").reply.ename); }