summaryrefslogtreecommitdiff
path: root/src/ninep/cols.zig
blob: cc583c93883f16e2e53323dd2e777bba7e36fc7c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
//! 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 };
    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/<n>/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, in its error alone.
    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(std.mem.startsWith(u8, full.reply.ename, "column not empty"));
    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/<serial>/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);
}