summaryrefslogtreecommitdiff
path: root/src/ninep/ctl.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep/ctl.zig')
-rw-r--r--src/ninep/ctl.zig503
1 files changed, 503 insertions, 0 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
new file mode 100644
index 00000000..d61ebf75
--- /dev/null
+++ b/src/ninep/ctl.zig
@@ -0,0 +1,503 @@
+//! The two ctl files. `look TEXT` is a right click and `exec TEXT` a middle
+//! click in the editor's own command language; a pane's ctl adds the addr
+//! verbs. Reading /ctl answers the serials the last command made or touched.
+const std = @import("std");
+const pardes = @import("../pardes.zig");
+const panes = @import("../panes.zig");
+const config = @import("../config.zig");
+const fs = @import("../fs.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;
+
+const Verb = enum {
+ @"addr=dot",
+ clean,
+ cleartag,
+ dirty,
+ @"dot=addr",
+ get,
+ @"limit=addr",
+ mark,
+ nomark,
+ noscroll,
+ scroll,
+ show,
+};
+
+fn verbIs(line: []const u8, word: []const u8) bool {
+ if (!std.mem.startsWith(u8, line, word)) return false;
+ return line.len == word.len or line[word.len] == ' ' or line[word.len] == '\t';
+}
+
+/// The text after `look` or `exec`, or null when the line is not that verb.
+/// Control characters other than tab are refused.
+fn textAfter(line: []const u8, verb: []const u8) ?[]const u8 {
+ if (!verbIs(line, verb)) return null;
+ const text = std.mem.trim(u8, line[verb.len..], " \t");
+ if (text.len == 0) return "";
+ for (text) |c| if (c < ' ' and c != '\t') return "";
+ return text;
+}
+
+// ---- 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 has(s: *const Serials, serial: u32) bool {
+ for (s.list[0..s.len]) |known| if (known == serial) return true;
+ return false;
+ }
+};
+
+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;
+}
+
+fn activeSerial(p: *Pardes) ?u32 {
+ const pane = p.panes[p.active] orelse return null;
+ return pane.serial;
+}
+
+/// 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;
+ const before = Serials.snapshot(p);
+ if (exec) _ = p.execute(id, text) else p.lookAt(id, text);
+ const after = Serials.snapshot(p);
+ var created = false;
+ for (after.list[0..after.len]) |serial| if (!before.has(serial)) {
+ noteResult(p, serial);
+ created = true;
+ };
+ if (created) return;
+ noteResult(p, if (exec) context else activeSerial(p) orelse return);
+}
+
+fn beginResults(p: *Pardes) void {
+ p.fs.results_len = 0;
+ p.fs.results_valid = true;
+}
+
+// ---- /ctl ----
+
+pub fn writeRoot(p: *Pardes, req: Req) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ if (apply) beginResults(p);
+ 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;
+ const exec = verbIs(line, "exec");
+ const text = textAfter(line, if (exec) "exec" else "look") orelse return Reply.fail(req.tag, E.INVAL);
+ if (text.len == 0) return Reply.fail(req.tag, E.INVAL);
+ if (!apply) continue;
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
+ run(p, p.active, exec, text);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+pub fn rootText(p: *Pardes, buf: []u8) []const u8 {
+ var w = std.Io.Writer.fixed(buf);
+ if (p.fs.results_valid) {
+ for (p.fs.results[0..p.fs.results_len]) |serial| w.print("{d}\n", .{serial}) catch break;
+ return w.buffered();
+ }
+ 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 root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len);
+
+pub fn readRoot(p: *Pardes, req: Req) Reply {
+ var buf: [root_text_cap]u8 = undefined;
+ const text = rootText(p, &buf);
+ const out = p.fs.stage(p.gpa);
+ out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
+ return tree.stagedReply(p, req);
+}
+
+// ---- /pane/<n>/ctl ----
+
+fn fontName(p: *Pardes) []const u8 {
+ const name = p.settings.font.effective_name.get();
+ return if (name.len == 0) "default" else name;
+}
+
+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;
+
+/// 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.
+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,
+ pane_files.bodyOf(pane).len,
+ @as(u32, 0),
+ @intFromBool(pane_files.dirtyOf(pane)),
+ pane.cols,
+ }) catch return w.buffered();
+ writeQuoted(&w, fontName(p)) catch return w.buffered();
+ w.print(" {d:>11} ", .{config.tab_width}) catch {};
+ return w.buffered();
+}
+
+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);
+}
+
+pub fn writePane(p: *Pardes, req: Req, serial: u32) Reply {
+ for ([2]bool{ false, true }) |apply| {
+ if (apply) beginResults(p);
+ 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;
+ const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT);
+ const errno = paneVerb(p, live, line, apply);
+ if (errno != 0) return Reply.fail(req.tag, errno);
+ }
+ }
+ return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+}
+
+fn paneVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) u16 {
+ const pane = p.panes[id] orelse return E.INVAL;
+ const pf = &p.fs.panes[id];
+
+ for ([_]bool{ false, true }) |exec| if (textAfter(line, if (exec) "exec" else "look")) |text| {
+ if (text.len == 0) return E.INVAL;
+ if (apply) run(p, id, exec, text);
+ return 0;
+ };
+ const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL;
+ if (!apply) return 0;
+
+ switch (v) {
+ .@"addr=dot" => pf.addr = pane_files.dotOf(pane),
+ .@"dot=addr" => {
+ pane_files.clampAddr(pf, pane_files.bodyOf(pane).len);
+ pane_files.setDot(pane, pf.addr);
+ },
+ .@"limit=addr" => {
+ pane_files.clampAddr(pf, pane_files.bodyOf(pane).len);
+ pf.limit = pf.addr;
+ },
+ .clean => if (pane_files.fileOf(pane)) |f| {
+ f.saved_revision = f.revision;
+ },
+ .dirty => if (pane_files.fileOf(pane)) |f| {
+ f.saved_revision = f.revision -% 1;
+ },
+ .cleartag => {
+ pane.tag_tail_len = 0;
+ pane.tag_init = true;
+ },
+ .get => if (pane_files.fileOf(pane)) |f| {
+ if (panes.Output.fileTraits(f.output).saves) {
+ if (fs.read(p, f.path)) |bytes| {
+ panes.File.pushUndo(p, pane);
+ panes.File.setContent(p, f, bytes);
+ f.saved_revision = f.revision;
+ } else |err| return switch (err) {
+ error.FileNotFound => E.NOENT,
+ else => E.IO,
+ };
+ }
+ },
+ .mark => {
+ pf.nomark = false;
+ panes.File.pushUndo(p, pane);
+ },
+ .nomark => pf.nomark = true,
+ .noscroll => pf.noscroll = true,
+ .scroll => pf.noscroll = false,
+ .show => pane_files.showOffset(pane, pane_files.dotOf(pane).q0),
+ }
+ 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_ctl = @intFromEnum(tree.TopFile.ctl);
+
+test "pane ctl read is index's five fields plus width in cells, font and tab width" {
+ 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} ", .{
+ 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 "every pane ctl verb, and every refusal" {
+ 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);
+ const pane = p.panes[0].?;
+ const pf = &p.fs.panes[0];
+
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "nomark\nnoscroll\ndirty\n").reply.status);
+ try testing.expect(pf.nomark and pf.noscroll and pane_files.dirtyOf(pane));
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "mark\nscroll\nclean\n").reply.status);
+ try testing.expect(!pf.nomark and !pf.noscroll and !pane_files.dirtyOf(pane));
+
+ _ = wr(p, ctl_node, "cleartag\n");
+ try testing.expectEqual(@as(usize, 0), pane.tag_tail_len);
+
+ _ = wr(p, Node.of(serial, .addr), "2");
+ _ = wr(p, ctl_node, "limit=addr\n");
+ try testing.expectEqual(@as(u32, 4), pf.limit.?.q0);
+ _ = wr(p, ctl_node, "dot=addr\nshow\n");
+ try testing.expectEqual(@as(i32, 1), pane.cur_row);
+
+ for ([_][]const u8{
+ "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL",
+ "name x.txt", "put", "del", "delete", "Look x", "Exec Save",
+ }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno());
+
+ try testing.expect(!pane_files.dirtyOf(pane));
+ try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nbogus\n").errno());
+ try testing.expect(!pane_files.dirtyOf(pane));
+}
+
+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, .ctl), "exec Msg from ctl\n").reply.status);
+ try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]);
+ var expected: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{serial}), rd(p, root_ctl, 0, 4096).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "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_ctl, 0, 4096).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "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_ctl, 0, 4096).bytes);
+}
+
+test "the root ctl reads editor status until a command runs and validates whole batches" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "Msg root\n");
+ defer p.deinit();
+ const status = rd(p, root_ctl, 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().?);
+ try testing.expectEqualStrings("panes 1", lines.next().?);
+ try testing.expect(lines.next() == null);
+ const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl });
+ try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size);
+ try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode);
+
+ const serial = p.next_serial;
+ for ([_][]const u8{ "look", "exec", "look \t", "Look /virtual/index", "exec Msg a\x00b", "exec Msg x\nbogus\n", "name x", "put" }) |invalid| {
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, invalid).errno());
+ try testing.expectEqual(serial, p.next_serial);
+ }
+ try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes);
+
+ const pane = p.panes[0].?;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "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_ctl, 0, 4096).bytes);
+
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "look /virtual/index\nexec New\n").reply.status);
+ var made: [MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| if (slot) |q| if (q.serial != pane.serial) {
+ made[n] = q.serial;
+ n += 1;
+ };
+ try testing.expectEqual(@as(usize, 2), n);
+ const listed = rd(p, root_ctl, 0, 4096);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n{d}\n", .{ @min(made[0], made[1]), @max(made[0], made[1]) }), listed.bytes);
+ const opened = p.panes[p.paneBySerial(@min(made[0], made[1])).?].?;
+ try testing.expectEqualStrings("/virtual/index", opened.file.?.path);
+}
+
+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 ctl_node = Node.of(source.serial, .ctl);
+ const revision = source.file.?.revision;
+ const undo_len = source.file.?.history.undo_len;
+ source.cur_col = 3;
+ var command: [4200]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, try std.fmt.bufPrint(&command, "look {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.cur_row);
+ try testing.expectEqual(@as(i32, 2), opened.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.cur_col);
+ var expected: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes);
+
+ p.active = 0;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status);
+ try testing.expect(p.panes[p.active].? == opened);
+ try testing.expectEqual(@as(i32, 2), opened.cur_row);
+ try testing.expectEqual(@as(i32, 1), opened.cur_col);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 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 ctl_node = Node.of(source.serial, .ctl);
+ const serial = p.next_serial;
+ for ([_][]const u8{
+ "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n",
+ }) |invalid| {
+ try testing.expectEqual(E.INVAL, wr(p, ctl_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, ctl_node, "look /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\nexec Del\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, .ctl), "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").?);
+}