summaryrefslogtreecommitdiff
path: root/src/ninep/ctl.zig
blob: deb6456501e2e30b817cd77cbae01b99cb51f140 (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
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
//! The command files and the status they answer. Writing a line to `look` is
//! a right click on it and writing one to `exec` a middle click, at the
//! active pane from the root and at that pane from /pane/<n>/; reading either
//! answers the serials the last command made or touched. /status reports the
//! editor. The root ctl reads the settings and takes the session's builtins;
//! a pane's ctl reads its acme status line and takes acme's verbs `get`,
//! `lock` and `unlock` and the builtins that act on a pane.
const std = @import("std");
const pardes = @import("../pardes.zig");
const look = @import("../look.zig");
const panes = @import("../panes.zig");
const config = @import("../config.zig");
const fs = @import("../fs.zig");
const tagline = @import("../tagline.zig");
const layout = @import("../layout.zig");
const builtins = @import("../builtins.zig");
const exec_line = @import("../exec.zig");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");

const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
const MAX_PANES = pardes.MAX_PANES;
const Req = tree.Req;
const Reply = tree.Reply;
const E = tree.E;

// ---- which panes a command made or touched ----

const Serials = struct {
    list: [MAX_PANES]u32 = undefined,
    len: usize = 0,

    fn snapshot(p: *Pardes) Serials {
        var s: Serials = .{};
        for (p.panes) |slot| if (slot) |pane| {
            s.list[s.len] = pane.serial;
            s.len += 1;
        };
        return s;
    }
};

fn noteResult(p: *Pardes, serial: u32) void {
    for (p.fs.results[0..p.fs.results_len]) |known| if (known == serial) return;
    if (p.fs.results_len == p.fs.results.len) return;
    p.fs.results[p.fs.results_len] = serial;
    p.fs.results_len += 1;
}

/// 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 {
    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),
    // and a line written to a look or exec is a click: on that pane, or for
    // the root's, on the pane with the keyboard. The reader writes the
    // record back to have it done.
    if (p.panes[id].?.fs.readers > 0) {
        const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null;
        var flag: u32 = pardes.ctlfs.events.flag_expansion;
        if (named) flag |= pardes.ctlfs.events.flag_builtin;
        if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename;
        _ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text);
        return noteResult(p, context);
    }
    const before = Serials.snapshot(p);
    // An exec answers the pane it went to: a command pane it reused, or
    // the terminal it typed into.
    const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
        look.lookAt(p, id, text);
        break :blk null;
    };
    const after = Serials.snapshot(p);
    var created = false;
    for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
        noteResult(p, serial);
        created = true;
    };
    if (created) return;
    if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial);
    noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}

// ---- look and exec ----

/// One command per line, checked whole before any line runs: a line is the
/// clicked text itself, so it may not be empty or carry control characters.
/// A null `serial` clicks at whichever pane is active as each line runs,
/// which is what the root's own look and exec mean; a serial stays put.
pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
    for ([2]bool{ false, true }) |apply| {
        if (apply) p.fs.results_len = 0;
        var it = std.mem.splitScalar(u8, req.data, '\n');
        while (it.next()) |raw| {
            const text = std.mem.trim(u8, raw, " \t\r");
            if (text.len == 0) continue;
            for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
            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);
            run(p, id, exec, text);
        }
    }
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

pub fn resultsLen(p: *Pardes) u64 {
    var n: u64 = 0;
    for (p.fs.results[0..p.fs.results_len]) |serial| {
        var digits: [16]u8 = undefined;
        n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len;
    }
    return n;
}

pub fn readResults(p: *Pardes, req: Req) Reply {
    const out = p.fs.stage(p.gpa);
    for (p.fs.results[0..p.fs.results_len]) |serial|
        out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

// ---- /status ----

fn statusText(p: *Pardes, buf: []u8) []const u8 {
    var w = std.Io.Writer.fixed(buf);
    var count: usize = 0;
    for (p.panes) |slot| count += @intFromBool(slot != null);
    const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0;
    w.print("pid {d}\nversion {s}\npanes {d}\n", .{ pid, pardes.version, count }) catch {};
    return w.buffered();
}

pub const status_text_cap = 64 + pardes.version.len;

pub fn statusLen(p: *Pardes) u64 {
    var buf: [status_text_cap]u8 = undefined;
    return statusText(p, &buf).len;
}

pub fn readStatus(p: *Pardes, req: Req) Reply {
    var buf: [status_text_cap]u8 = undefined;
    const out = p.fs.stage(p.gpa);
    out.appendSlice(p.gpa, statusText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

// ---- /pane/<n>/ctl ----

fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void {
    const plain = s.len > 0 and for (s) |c| {
        if (c <= ' ' or c == '\'') break false;
    } else true;
    if (plain) return w.writeAll(s);
    try w.writeByte('\'');
    for (s) |c| {
        if (c == '\'') try w.writeByte('\'');
        try w.writeByte(c);
    }
    try w.writeByte('\'');
}

pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len;

/// acme's winctlprint: serial, tag length, body length, a reserved zero, the
/// dirty flag, then the width in cells, the font and the tab width. Then
/// rio's word for whether the window has the keyboard, `current` or
/// `notcurrent` (rio(4), wctl), which acme's line has no room for because
/// acme has one window for input at a time only by the mouse.
pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
    var w = std.Io.Writer.fixed(buf);
    w.print("{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} ", .{
        pane.serial,
        pane_files.tagOf(p, pane).len,
        // A terminal's body is its history, read whole on an open's first read.
        if (pane.isTerminal()) panes.Terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len,
        @as(u32, 0),
        @intFromBool(pane_files.dirtyOf(pane)),
        pane.cols,
    }) catch return w.buffered();
    const font = p.settings.font.effective_name.get();
    writeQuoted(&w, if (font.len == 0) "default" else font) catch return w.buffered();
    // While a column's or the workspace's tag has the keyboard, no pane does.
    const current = p.panes[p.active] == pane and !p.header_focus;
    w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {};
    return w.buffered();
}

// ---- control messages: the builtins, by scope ----

const Builtin = builtins.registry.Builtin();

/// Refuses a control message, quoting it the way Plan 9's cmderror does
/// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`.
fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
    const room = p.fs.ename.len - why.len - 3;
    const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why;
    return tree.failText(req.tag, E.INVAL, text);
}

/// Checks one line written to a ctl as a builtin of `scope` before any line
/// of the write runs: a word the registry knows, of this ctl's scope, given
/// an argument if and only if it takes or requires one, and for a setting a
/// value it takes.
/// Answers the refusal, or null. Words are the builtins' own, capitalised
/// as on a tag; acme's lowercase verbs are the pane ctl's and alias none.
fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?Reply {
    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);
    if (builtins.registry.scope(b) != scope)
        return refuse(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line);
    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;
    const takes = switch (setting.action) {
        .theme => for (pardes.themes) |t| {
            if (std.mem.eql(u8, t.name, arg)) break true;
        } else {
            // `refuse` quotes the line after it, in what is left of 128.
            var near: [40]u8 = undefined;
            var why: [104]u8 = undefined;
            return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
        },
        .font => config.Runtime.FontSpec.parse(arg) != null,
        else => probe: {
            var probe = p.settings;
            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);
}

/// 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 {
    p.fs.no_prompt = true;
    p.fs.refused = false;
    p.fs.failure_len = 0;
    defer p.fs.no_prompt = false;
    // What a ctl write touched reads back from /look and /exec as a click's
    // does (`run`), so a script that made a pane with `Tty` finds it there.
    const context = p.panes[id].?.serial;
    const before = Serials.snapshot(p);
    _ = exec_line.executeBuiltinLine(p, id, line);
    const after = Serials.snapshot(p);
    var created = false;
    for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
        noteResult(p, serial);
        created = true;
    };
    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);
    return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename };
}

const e_prompt = "control message needs its argument";

// ---- the root ctl ----

/// Every setting, one a line, in the words a write of it takes, so that the
/// file read back is the configuration, as webfs's ctl reads (webfs(4)).
/// 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;
    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 }),
            .shell => {
                const chosen = s.shell.requested.get();
                try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
            },
            .font => {
                const requested = s.font.requested_name.get();
                const name = if (requested.len > 0) requested else s.font.effective_name.get();
                const size = if (s.font.requested_size_hundredths != 0) s.font.requested_size_hundredths else s.font.effective_size_hundredths;
                if (name.len > 0 and size != 0)
                    try w.print("{s} {s}:{d}.{d:0>2}\n", .{ word, name, size / 100, size % 100 })
                else if (name.len > 0)
                    try w.print("{s} {s}\n", .{ word, name });
            },
            .tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }),
            .window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }),
            .window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }),
            .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}),
            .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }),
            .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }),
        }
    }
    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 {
    var buf: [256]u8 = undefined;
    var counting: std.Io.Writer.Discarding = .init(&buf);
    writeSettings(p, &counting.writer) catch return 0;
    return counting.fullCount();
}

pub fn readRoot(p: *Pardes, req: Req) Reply {
    var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
    writeSettings(p, &text.writer) catch return Reply.fail(req.tag, E.NOMEM);
    p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

/// One session builtin a line, at whichever pane has the keyboard as each
/// runs, as the root's look and exec click there; all are checked first.
pub fn writeRoot(p: *Pardes, req: Req) Reply {
    for ([2]bool{ false, true }) |apply| {
        if (apply) p.fs.results_len = 0;
        var it = std.mem.splitScalar(u8, req.data, '\n');
        while (it.next()) |raw| {
            const line = std.mem.trim(u8, raw, " \t\r");
            if (line.len == 0) continue;
            if (!apply) {
                if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
                continue;
            }
            if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
            if (runBuiltin(p, req, p.active, line)) |refusal| return refusal;
        }
    }
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

// ---- /commands ----

/// Every builtin the registry holds, one a line: its word, `arg` when it
/// takes one, and which ctl takes it, `root` or `pane` -- the registry
/// 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", .{
            @tagName(b),
            if (builtins.registry.takesArg(b)) " arg" else "",
            if (builtins.registry.scope(b) == .session) "root" else "pane",
        });
    }
}

pub fn commandsLen() u64 {
    var buf: [256]u8 = undefined;
    var counting: std.Io.Writer.Discarding = .init(&buf);
    writeCommands(&counting.writer) catch return 0;
    return counting.fullCount();
}

pub fn readCommands(p: *Pardes, req: Req) Reply {
    var text: std.Io.Writer.Allocating = .init(p.scratch.allocator());
    writeCommands(&text.writer) catch return Reply.fail(req.tag, E.NOMEM);
    p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

// ---- /focus ----

/// The pane with the keyboard, which a write of a serial moves, the way
/// writing `current` to rio's wctl does (rio(4)). While a column's or the
/// workspace's tag has it, no pane does, and this reads empty. A write
/// gives the keyboard and nothing else: a folded pane stays folded, as
/// rio keeps `current` apart from `unhide`.
pub fn readFocus(p: *Pardes, req: Req) Reply {
    const out = p.fs.stage(p.gpa);
    if (p.header_focus) return tree.stagedReply(p, req);
    const pane = p.panes[p.active] orelse return tree.stagedReply(p, req);
    out.print(p.gpa, "{d}\n", .{pane.serial}) catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

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.
    tagline.exitHeader(p);
    p.active = id;
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

const e_no_pane = "no such window";

pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply {
    var buf: [pane_text_cap]u8 = undefined;
    const out = p.fs.stage(p.gpa);
    out.appendSlice(p.gpa, paneText(p, pane, &buf)) catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

/// `get` reloads the buffer from the name it carries, wherever that name
/// resolves. Any other line is a builtin that acts on a pane (`Del`,
/// `Save f`), run at this one; a session's builtin is the root ctl's.
///
/// `lock` and `unlock` are acme's (editors/acme/xfid.c:603-611), so that a
/// client can make an edit of several writes to addr and data without
/// another's landing in between. As in acme the lock binds only the clients
/// that ask for it; a write to any other file is never refused for it, nor
/// is the person at the keyboard. It belongs to the open that wrote it,
/// which alone may `unlock`, and closing that open or the pane gives it up.
/// Where acme's qlock blocks a second locker, this refuses it at once with
/// `file in use`, and the client tries again: through a kernel or FUSE
/// mount a blocked write holds the file's writes, the holder's own `unlock`
/// and close among them, so a waiting lock would never be let in.
pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
    // This open's handle is what holds the lock; a write that came on no
    // writable open (the editor's own) has none.
    const mine: ?u32 = if (tree.openOf(p, req)) |o| (if (o.what == .ctl) req.handle else null) else null;
    // The pane keeps the holder's handle, which names that open only on
    // this pane's ctl node; one that no longer does holds nothing.
    if (pane.fs.lock) |h| {
        const o = tree.openOf(p, .{ .tag = 0, .op = .write, .node = tree.Node.of(pane.serial, .ctl), .handle = h });
        if (o == null or o.?.what != .ctl) pane.fs.lock = null;
    }
    const other = pane.fs.lock != null and pane.fs.lock != mine;
    const serial = pane.serial;
    // Checked whole before anything applies, so a write that must wait for
    // the lock has done nothing yet when it goes again.
    for ([2]bool{ false, true }) |apply| {
        if (apply) p.fs.results_len = 0;
        var held = !other and pane.fs.lock != null;
        var it = std.mem.splitScalar(u8, req.data, '\n');
        while (it.next()) |raw| {
            const line = std.mem.trim(u8, raw, " \t\r");
            if (line.len == 0) continue;
            // A line before this one may have closed the pane (`Del`).
            if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
            if (std.mem.eql(u8, line, "get")) {
                if (!apply) continue;
                const errno = get(p, pane);
                if (errno != 0) return Reply.fail(req.tag, errno);
            } else if (std.mem.eql(u8, line, "lock")) {
                if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
                if (other) return tree.failText(req.tag, E.BUSY, tree.e_in_use);
                held = true;
                if (apply) pane.fs.lock = mine;
            } else if (std.mem.eql(u8, line, "unlock")) {
                if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked);
                held = false;
                if (apply) pane.fs.lock = null;
            } else if (!apply) {
                if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
            } else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| {
                return refusal;
            }
        }
    }
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

const e_not_locked = "window not locked by this open";

fn get(p: *Pardes, pane: *Pane) u16 {
    const f = pane_files.fileOf(pane) orelse return 0;
    if (!panes.Output.fileTraits(f.output).saves) return 0;
    const bytes = fs.read(p, f.path) catch |err| return switch (err) {
        error.FileNotFound => E.NOENT,
        else => E.IO,
    };
    panes.File.pushUndo(p, pane);
    panes.File.setContent(p, f, bytes);
    f.saved_revision = f.revision;
    return 0;
}

// ---- tests ----

const testing = std.testing;
const th = @import("testing.zig");
const call = th.call;
const rd = th.rd;
const wr = th.wr;
const withFile = th.withFile;
const serialOf = th.serialOf;
const Node = tree.Node;
const Status = tree.Status;
const root_status = @intFromEnum(tree.TopFile.status);
const root_look = @intFromEnum(tree.TopFile.look);
const root_exec = @intFromEnum(tree.TopFile.exec);

test "pane ctl read is index's five fields plus width in cells, font, tab width and whether it is current" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "x\n");
    defer p.deinit();
    const pane = p.panes[0].?;

    const a = rd(p, Node.of(pane.serial, .ctl), 0, 4096);
    try testing.expectEqual(Status.ok, a.reply.status);
    var want: std.ArrayList(u8) = .empty;
    defer want.deinit(gpa);
    try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s} {d:>11} current ", .{
        pane.serial, pane_files.tagOf(p, pane).len, @as(usize, 2), 0, 0, pane.cols, "default", config.tab_width,
    });
    try testing.expectEqualStrings(want.items, a.bytes);
    try testing.expectEqual(@as(u64, a.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = Node.of(pane.serial, .ctl) }).reply.attr.size);

    var quoted: [64]u8 = undefined;
    var w = std.Io.Writer.fixed(&quoted);
    try writeQuoted(&w, "DejaVu Sans Mono");
    try testing.expectEqualStrings("'DejaVu Sans Mono'", w.buffered());
    w = std.Io.Writer.fixed(&quoted);
    try writeQuoted(&w, "it's");
    try testing.expectEqualStrings("'it''s'", w.buffered());
}

test "commands lists every builtin with its argument and the ctl that takes it" {
    const p = try withFile(testing.allocator, "x\n");
    defer p.deinit();
    const node = @intFromEnum(tree.TopFile.commands);
    const listed = rd(p, node, 0, 1 << 16);
    try testing.expectEqual(@as(u64, listed.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size);
    try testing.expectEqual(E.PERM, wr(p, node, "Del\n").errno());
    var lines: usize = 0;
    var it = std.mem.tokenizeScalar(u8, listed.bytes, '\n');
    while (it.next()) |line| : (lines += 1) {
        var words = std.mem.tokenizeScalar(u8, line, ' ');
        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().?);
        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" }) |row|
        try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}

test "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" {
    const p = try withFile(testing.allocator, "x\n");
    defer p.deinit();
    const first = serialOf(p);
    const second = try th.newPane(p);
    const focus = @intFromEnum(tree.TopFile.focus);
    var line: [16]u8 = undefined;
    try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{second}), rd(p, focus, 0, 64).bytes);
    try testing.expectEqual(@as(u64, rd(p, focus, 0, 64).bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size);
    try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " notcurrent "));

    // A header has the keyboard: no pane does, and a write takes it back.
    tagline.enterHeader(p, null);
    try testing.expectEqualStrings("", rd(p, focus, 0, 64).bytes);
    try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = focus }).reply.attr.size);
    try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(second, .ctl), 0, 4096).bytes, " notcurrent "));
    try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}\n", .{first})).reply.status);
    try testing.expect(!p.header_focus);
    try testing.expectEqual(p.paneBySerial(first).?, p.active);
    try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(first, .ctl), 0, 4096).bytes, " current "));
    try testing.expect(std.mem.endsWith(u8, rd(p, Node.of(second, .ctl), 0, 4096).bytes, " notcurrent "));

    try testing.expectEqualStrings(e_no_pane, wr(p, focus, "99999\n").reply.ename);
    try testing.expectEqual(E.NOENT, wr(p, focus, "99999").errno());
    for ([_][]const u8{ "", "two", "-1", "1 2" }) |bad| try testing.expectEqual(E.INVAL, wr(p, focus, bad).errno());
    try testing.expectEqual(p.paneBySerial(first).?, p.active);

    // Focus gives the keyboard only: a folded pane stays folded.
    const folded = p.paneBySerial(second).?;
    layout.toggleCollapse(p, folded);
    try testing.expect(p.panes[folded].?.collapsed);
    try testing.expectEqual(Status.ok, wr(p, focus, try std.fmt.bufPrint(&line, "{d}", .{second})).reply.status);
    try testing.expectEqual(folded, p.active);
    try testing.expect(p.panes[folded].?.collapsed);
}

test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the rest in Plan 9's words" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "one\ntwo\n");
    defer p.deinit();
    const serial = serialOf(p);
    const ctl_node = Node.of(serial, .ctl);
    // acme's other ctl words have files of their own here, and a builtin is
    // its own capitalised word: `Get`, `DEL` and `del` are none of them.
    for ([_][]const u8{
        "menu", "nomenu",     "dump echo hi", "font Go Mono", "lock x", "bogus", "DEL",
        "put",  "del",        "delete",       "clean",        "dirty",  "show",  "mark",
        "Get",  "limit=addr", "get x",        "look /tmp",    "exec Del",
    }) |bad| {
        const refused = wr(p, ctl_node, bad);
        try testing.expectEqual(E.INVAL, refused.errno());
        var want: [64]u8 = undefined;
        try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{bad}), refused.reply.ename);
    }
    try testing.expectEqualStrings("not a window control message \"Newcol\"", wr(p, ctl_node, "Newcol").reply.ename);
    try testing.expectEqualStrings("not a window control message \"Verbose off\"", wr(p, ctl_node, "Verbose off").reply.ename);
    try testing.expectEqualStrings("wrong #args in control message \"Collapse now\"", wr(p, ctl_node, "Collapse now").reply.ename);
    // Checked whole first: a bad line anywhere and nothing ran.
    try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nbogus").errno());
    try testing.expect(p.panes[0].?.msg_len == 0 or !std.mem.eql(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "first"));

    // A pane's builtin runs at that pane.
    try testing.expectEqual(Status.ok, wr(p, ctl_node, "Msg from ctl\n").reply.status);
    const pane = p.panes[p.paneBySerial(serial).?].?;
    try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]);
    // One that means nothing bare is refused before anything runs.
    try testing.expectEqualStrings("wrong #args in control message \"Find\"", wr(p, ctl_node, "Find").reply.ename);
    try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nMsg").errno());
    try testing.expect(!std.mem.eql(u8, pane.msg[0..pane.msg_len], "first"));
    try testing.expect(pane.prompt == .none);
    try testing.expect(!p.fs.no_prompt);
    // Save with no name on a scratch would ask for one: refused, not asked.
    const other = try th.newPane(p);
    try testing.expectEqualStrings(e_prompt ++ " \"Save\"", wr(p, Node.of(other, .ctl), "Save").reply.ename);
    try testing.expect(p.panes[p.paneBySerial(other).?].?.prompt == .none);
    // A line after the one that closed the pane has nowhere to run.
    try testing.expectEqual(E.NOENT, wr(p, Node.of(other, .ctl), "Del\nMsg after").errno());
    try testing.expect(p.paneBySerial(other) == null);
}

test "the root ctl reads the settings as a write takes them, and takes the session's builtins" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "x\n");
    defer p.deinit();
    const root_ctl = @intFromEnum(tree.TopFile.ctl);
    const first = rd(p, root_ctl, 0, 1 << 16);
    try testing.expectEqual(Status.ok, first.reply.status);
    const settings = try gpa.dupe(u8, first.bytes);
    defer gpa.free(settings);
    try testing.expectEqual(@as(u64, settings.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }).reply.attr.size);
    try testing.expect(std.mem.indexOf(u8, settings, "Verbose on\n") != null);
    try testing.expect(std.mem.indexOf(u8, settings, "LocationsConfig ") != null);
    // Settings only: nothing /status or the Config window says.
    try testing.expect(std.mem.indexOf(u8, settings, "Platform") == null);
    try testing.expect(std.mem.indexOf(u8, settings, "requested") == null);

    // Every line written back is taken, and changes nothing.
    try testing.expectEqual(Status.ok, wr(p, root_ctl, settings).reply.status);
    try testing.expectEqualStrings(settings, rd(p, root_ctl, 0, 1 << 16).bytes);

    try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off\n").reply.status);
    try testing.expect(!p.settings.verbose);
    try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Verbose off\n") != null);
    const columns = p.ncol;
    try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
    try testing.expectEqual(columns + 1, p.ncol);

    try testing.expectEqualStrings("not a session control message \"Del\"", 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);
    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\""));
    try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
    try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename);
    // A bare required word fails the check, so the line before never runs.
    try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Verbose on\nMount").errno());
    try testing.expect(!p.settings.verbose);
    // A builtin that fails as it runs fails the write, quoting its error and
    // its line; the lines before it have taken effect, as in acme.
    const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off");
    try testing.expectEqual(E.IO, failed.errno());
    try testing.expectEqualStrings("Mount name dial: MissingArgument \"Mount x\"", failed.reply.ename);
    try testing.expect(p.settings.verbose);
    try testing.expect(!p.fs.no_prompt);
    try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status);
}


test "a second lock is refused until the holder unlocks or closes, and binds nobody else" {
    const p = try withFile(testing.allocator, "one\ntwo\n");
    defer p.deinit();
    const serial = serialOf(p);
    const ctl_node = Node.of(serial, .ctl);
    const w = struct {
        fn ctl(pp: *Pardes, node: u64, h: u32, data: []const u8) th.Answer {
            return call(pp, .{ .tag = 4, .op = .write, .node = node, .handle = h, .data = data });
        }
    };
    const a = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 2 }).reply.handle;
    const b = call(p, .{ .tag = 2, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
    try testing.expect(a != 0 and b != 0 and a != b);
    // Reading the status line holds nothing, so it cannot lock either.
    try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 3, .op = .open, .node = ctl_node }).reply.handle);
    try testing.expectEqual(E.INVAL, wr(p, ctl_node, "lock\n").errno());

    try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\n").reply.status);
    try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\n").reply.status);
    // Another open's lock is refused at once, and so is its unlock; writes
    // to the pane's other files are not.
    try testing.expectEqualStrings(tree.e_in_use, w.ctl(p, ctl_node, b, "lock\n").reply.ename);
    try testing.expectEqual(E.BUSY, w.ctl(p, ctl_node, b, "lock\n").errno());
    try testing.expectEqualStrings(e_not_locked, w.ctl(p, ctl_node, b, "unlock\n").reply.ename);
    try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .addr), "1").reply.status);
    try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "ONE\n").reply.status);
    try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content);

    // Once the holder unlocks, the other's try takes it.
    try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "unlock\n").reply.status);
    try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, b, "lock\n").reply.status);
    try testing.expectEqual(E.BUSY, w.ctl(p, ctl_node, a, "lock\nunlock\n").errno());
    // Closing the holder gives it up.
    _ = call(p, .{ .tag = 5, .op = .release, .node = ctl_node, .handle = b });
    try testing.expectEqual(Status.ok, w.ctl(p, ctl_node, a, "lock\nunlock\nlock\n").reply.status);
    try testing.expectEqual(E.INVAL, w.ctl(p, ctl_node, a, "unlock\nunlock\n").errno());
    try testing.expectEqual(@as(?u32, a), p.panes[0].?.fs.lock);

    // The lock lives and dies with the pane.
    const other = try th.newPane(p);
    const other_ctl = Node.of(other, .ctl);
    const c = call(p, .{ .tag = 6, .op = .open, .node = other_ctl, .omode = 1 }).reply.handle;
    try testing.expectEqual(Status.ok, w.ctl(p, other_ctl, c, "lock\n").reply.status);
    try testing.expectEqual(Status.ok, th.rmdir(p, Node.of(other, .dir)).reply.status);
    try testing.expectEqual(E.NOENT, w.ctl(p, other_ctl, c, "lock\n").errno());
    for ([_]u64{ ctl_node, other_ctl }, [_]u32{ a, c }) |node, h|
        _ = call(p, .{ .tag = 7, .op = .release, .node = node, .handle = h });
    try testing.expect(p.panes[0].?.fs.lock == null);
    for (p.fs.opens) |o| try testing.expect(o.node == 0);
}

test "exec runs a builtin at the pane and records the pane it acted on" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "Msg fs-ran\n");
    defer p.deinit();
    const serial = serialOf(p);
    const pane = p.panes[0].?;
    try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Msg from exec\n").reply.status);
    try testing.expectEqualStrings("from exec", pane.msg[0..pane.msg_len]);
    var expected: [16]u8 = undefined;
    const own = try std.fmt.bufPrint(&expected, "{d}\n", .{serial});
    try testing.expectEqualStrings(own, rd(p, root_exec, 0, 4096).bytes);
    // Either command file reads back what the last one did.
    try testing.expectEqualStrings(own, rd(p, Node.of(serial, .look), 0, 4096).bytes);
    try testing.expectEqual(@as(u64, own.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_exec }).reply.attr.size);

    try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "New\n").reply.status);
    const made = p.panes[p.active].?;
    try testing.expect(made.serial != serial);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes);

    try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .exec), "Del\n").reply.status);
    try testing.expect(p.paneBySerial(made.serial) == null);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes);
}

test "status reads the editor, and look and exec validate whole batches" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "Msg root\n");
    defer p.deinit();
    const status = rd(p, root_status, 0, 4096);
    try testing.expectEqual(Status.ok, status.reply.status);
    var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n');
    try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid "));
    var expected: [64]u8 = undefined;
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?);
    var live: usize = 0;
    for (p.panes) |slot| live += @intFromBool(slot != null);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "panes {d}", .{live}), lines.next().?);
    try testing.expect(lines.next() == null);
    const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_status });
    try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size);
    try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode);
    try testing.expectEqual(E.PERM, wr(p, root_status, "anything\n").errno());
    try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes);

    const serial = p.next_serial;
    for ([_][]const u8{ "Msg a\x00b", "Msg x\nMsg \x01y\n" }) |invalid| {
        try testing.expectEqual(E.INVAL, wr(p, root_exec, invalid).errno());
        try testing.expectEqual(serial, p.next_serial);
    }
    // An empty write is a no-op, not a command.
    try testing.expectEqual(Status.ok, wr(p, root_exec, "\n \n").reply.status);
    try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes);
    try testing.expectEqualStrings(status.bytes, rd(p, root_status, 0, 4096).bytes);

    const pane = p.panes[0].?;
    try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg hello there\n").reply.status);
    try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_exec, 0, 4096).bytes);

    try testing.expectEqual(Status.ok, wr(p, root_look, "/virtual/index\n").reply.status);
    const opened = p.panes[p.active].?;
    try testing.expectEqualStrings("/virtual/index", opened.file.?.path);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes);

    try testing.expectEqual(Status.ok, wr(p, root_exec, "New\n").reply.status);
    const made = p.panes[p.active].?;
    try testing.expect(made.serial != opened.serial);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes);
}

test "look opens spaced paths and locations without editing the source pane" {
    if (!fs.platform_has_fs) return error.SkipZigTest;
    const gpa = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "two words.zig", .data = "first\nsecond\nthird\n" });
    var path_buf: [4096]u8 = undefined;
    const path = path_buf[0..try tmp.dir.realPathFile(testing.io, "two words.zig", &path_buf)];
    const p = try withFile(gpa, "source stays intact\n");
    defer p.deinit();
    const source = p.panes[0].?;
    const look_node = Node.of(source.serial, .look);
    const revision = source.file.?.revision;
    const undo_len = source.file.?.history.undo_len;
    source.body.cur_col = 3;
    var clicked: [4200]u8 = undefined;
    try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&clicked, "{s}:2:3\n", .{path})).reply.status);
    const opened = p.panes[p.active].?;
    try testing.expect(opened != source);
    try testing.expectEqualStrings(path, opened.file.?.path);
    try testing.expectEqualStrings("first\nsecond\nthird\n", opened.file.?.content);
    try testing.expectEqual(@as(i32, 1), opened.body.cur_row);
    try testing.expectEqual(@as(i32, 2), opened.body.cur_col);
    try testing.expectEqualStrings("source stays intact\n", source.file.?.content);
    try testing.expectEqual(revision, source.file.?.revision);
    try testing.expectEqual(undo_len, source.file.?.history.undo_len);
    try testing.expectEqual(@as(i32, 3), source.body.cur_col);
    var expected: [16]u8 = undefined;
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes);

    p.active = 0;
    try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&clicked, "{s}:3:2\n", .{path})).reply.status);
    try testing.expect(p.panes[p.active].? == opened);
    try testing.expectEqual(@as(i32, 2), opened.body.cur_row);
    try testing.expectEqual(@as(i32, 1), opened.body.cur_col);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes);
}

test "look validates the whole batch before opening virtual files" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "source\n");
    defer p.deinit();
    const source = p.panes[0].?;
    const look_node = Node.of(source.serial, .look);
    const serial = p.next_serial;
    for ([_][]const u8{
        "/virtual/index\x00ignored", "/virtual/index\n/virtual/\x01index\n",
    }) |invalid| {
        try testing.expectEqual(E.INVAL, wr(p, look_node, invalid).errno());
        try testing.expectEqual(serial, p.next_serial);
        try testing.expectEqual(@as(usize, 0), p.active);
        try testing.expectEqualStrings("source\n", source.file.?.content);
    }
    try testing.expectEqual(Status.ok, wr(p, look_node, "/n/self/index\n").reply.status);
    const opened = p.panes[p.active].?;
    try testing.expectEqualStrings("/virtual/index", opened.file.?.path);
    try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null);
    try testing.expectEqualStrings("source\n", source.file.?.content);
}

test "get reloads the pane from disk and reports a missing file without losing dirty contents" {
    const gpa = testing.allocator;
    var tmp = testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(testing.io, .{ .sub_path = "note.txt", .data = "from disk\n" });
    var directory_buf: [4096]u8 = undefined;
    const directory = directory_buf[0..try tmp.dir.realPath(testing.io, &directory_buf)];
    var path_buf: [4096]u8 = undefined;
    const path = try std.fmt.bufPrint(&path_buf, "{s}/note.txt", .{directory});

    const p = try withFile(gpa, "in memory\n");
    defer p.deinit();
    const serial = serialOf(p);
    const ctl_node = Node.of(serial, .ctl);
    const pane = p.panes[0].?;

    var name: [std.fs.max_path_bytes + 8]u8 = undefined;
    _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path}));
    try testing.expectEqual(Status.ok, wr(p, ctl_node, "get\n").reply.status);
    try testing.expectEqualStrings("from disk\n", pane.file.?.content);
    try testing.expect(!pane_files.dirtyOf(pane));
    try testing.expect(pane.file.?.history.undo_len > 0);

    _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory}));
    _ = wr(p, Node.of(serial, .body), "unsaved\n");
    try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno());
    try testing.expect(p.paneBySerial(serial) != null);
    try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
    try testing.expect(pane_files.dirtyOf(pane));
}

test "exec Save writes the named file through the host" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "one\ntwo\n");
    defer p.deinit();
    const serial = serialOf(p);
    const pane = p.panes[0].?;
    try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status);
    try testing.expect(pane_files.dirtyOf(pane));
    const saved = wr(p, Node.of(serial, .exec), "Save\n");
    try testing.expectEqual(Status.ok, saved.reply.status);
    try testing.expect(saved.saved);
    p.perform(.{ .save_file = .{ .pane = 0 } });
    try testing.expect(!pane_files.dirtyOf(pane));
    try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?);
}

test "a pane's own look and exec go to the event reader holding it, which writes them back to act" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "Msg routed\n");
    defer p.deinit();
    const serial = serialOf(p);
    const event = Node.of(serial, .event);
    const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle;
    try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg routed\n").reply.status);
    // Not done: the reader has it, as it would a middle click.
    const pane = p.panes[0].?;
    try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null);
    const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes;
    try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record);
    // Written back, it is done.
    try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status);
    try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null);
    try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status);
    try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
    // The root's is a click at the pane with the keyboard, this one.
    try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status);
    try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null);
    try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
    _ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
    // With no reader, the root's and the pane's act at once.
    try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status);
    try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "direct") != null);
}

test "look and exec read back what the last command touched, a ctl write's too" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "x\n");
    defer p.deinit();
    const serial = serialOf(p);
    var want: [32]u8 = undefined;
    _ = wr(p, root_exec, "Msg first\n");
    try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, root_exec, 0, 64).bytes);
    // A pane made through ctl is what both read next, not the Msg's pane.
    try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status);
    const made = p.panes[p.active].?.serial;
    try testing.expect(made != serial);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
    // A ctl write that makes nothing names the pane it ran at.
    try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status);
    try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
}

test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" {
    // 9ns (cloud9's 9ns/src/nine.zig) maps an error to an errno by its
    // words, and EIO when none of them match: these words must not.
    const addressing = @import("addr.zig");
    const einval = [_][]const u8{ "control message", "invalid", "bad " };
    const other = [_][]const u8{ "interrupt", "shut down", "not exist", "not found", "no such", "exists", "not empty", "not a dir", "is a dir", "permission", "denied", "read-only", "read only", "no space", "not allowed", "not permitted", "cannot", "fid", "busy", "in use", "too long", "not supported", "unsupported" };
    const Case = struct { text: []const u8, malformed: bool };
    for ([_]Case{
        .{ .text = addressing.e_syntax, .malformed = true },
        .{ .text = addressing.e_regexp, .malformed = true },
        .{ .text = e_prompt, .malformed = true },
        .{ .text = addressing.e_no_match, .malformed = false },
        .{ .text = addressing.e_range, .malformed = false },
        .{ .text = addressing.e_order, .malformed = false },
        .{ .text = addressing.e_slow, .malformed = false },
        .{ .text = "/tmp/x.txt: Modified (Exit again to discard)", .malformed = false },
    }) |c| {
        const says_einval = for (einval) |w| {
            if (std.ascii.findIgnoreCase(c.text, w) != null) break true;
        } else false;
        try testing.expectEqual(c.malformed, says_einval);
        for (other) |w| try testing.expect(std.ascii.findIgnoreCase(c.text, w) == null);
    }
}

test "Joincol with no column to the right and Theme with no such theme say so" {
    const gpa = testing.allocator;
    const p = try withFile(gpa, "x\n");
    defer p.deinit();
    const root_ctl = @intFromEnum(tree.TopFile.ctl);
    const joined = wr(p, root_ctl, "Joincol\n");
    try testing.expectEqual(tree.Status.err, joined.reply.status);
    try testing.expectStringStartsWith(joined.reply.ename, "Joincol: no column to the right");
    const themed = wr(p, root_ctl, "Theme drak\n");
    try testing.expectEqual(E.INVAL, themed.errno());
    try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: ");
    try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null);
    try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\""));
    // A click on the word says it on the message row.
    _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n");
    const pane = p.panes[p.active].?;
    try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null);
}