diff options
Diffstat (limited to 'src/ninep/tree.zig')
| -rw-r--r-- | src/ninep/tree.zig | 309 |
1 files changed, 278 insertions, 31 deletions
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index baf8c772..ed0e0352 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -66,7 +66,10 @@ pub const e_bad_event = "bad event syntax"; /// A second open reading a file whose reads consume: rio's word for it /// (windows/rio/xfid.c:25), which 9ns turns into EBUSY. pub const e_in_use = "file in use"; -pub const e_shut_down = "window shut down"; +/// A read held on a pane's event or pty/data that the pane closed under. +/// acme says "window shut down" (xfid.c:1005), which 9ns reads as EIO; +/// these words read as ENOENT, what every other file of a gone pane gives. +pub const e_shut_down = "no such pane: its window shut"; /// Tremove closes a pane; nothing else in the tree is created or destroyed by /// the protocol, and wstat stays a truncation. Making a pane is an open of @@ -82,21 +85,21 @@ pub const features: cloud9.fs.Features = .{ .remove = true, .create = true }; /// the mutation of its own step, so the layout and the surface are whole /// under it -- and a step out there is never waiting on a write of its own, /// so what parks here is never the syscall's own request. -pub fn needsQuiet(req: Req) bool { +pub fn needsQuiet(p: *Pardes, req: Req) bool { return switch (req.op) { .write, .setattr => true, - .release => req.remove, + .release => req.remove or holdsLines(p, req), else => false, }; } /// `needsQuiet`, plus the open that makes a pane: what costs a frame and a /// wake of the editor. -pub fn changesPane(req: Req) bool { +pub fn changesPane(p: *Pardes, req: Req) bool { return switch (req.op) { .write, .setattr => true, .open => req.node == @intFromEnum(TopFile.new), - .release => req.remove, + .release => req.remove or holdsLines(p, req), .lookup, .getattr, .read, .readdir => false, }; } @@ -125,7 +128,20 @@ pub const Open = struct { pty_data, /// An open that may write a pane's ctl, and so hold its lock. ctl, + /// An open of a file that takes lines of commands (look, exec, a + /// ctl, tagexec): its `pending` and `results`. + lines, } = .{ .snapshot = null }, + /// What a write to a file of command lines left unfinished: a last line + /// with no newline yet, or an Edit block still open. A write through a + /// mount is cut at the 9P message size, so one `echo` of many lines may + /// come as several writes, cut anywhere; the rest waits for the open's + /// next write, or its release, and never runs as a fragment. + pending: std.ArrayList(u8) = .empty, + /// The panes this open's last write touched, which a read of look or + /// exec on it answers, as /net/tcp/clone answers on the open that + /// asked; a fresh open holds the session's last, from before it opened. + results: Results = .{}, /// A read that found nothing yet (`.again`), kept to be answered when /// this open's file has something, the way factotum keeps its log's /// waiting reads and answers them on append (security/auth/factotum/ @@ -142,6 +158,16 @@ pub const Open = struct { pub const Replay = struct { off: u64, bytes: []u8 }; + pub const Results = struct { + list: [pardes.MAX_PANES]u32 = undefined, + len: u8 = 0, + /// Written on: reads are a stream from the write on, whatever + /// offset they come at, since a shell's `exec 3<>look` shares one + /// offset between the write and the read (as pty/run does). + wrote: bool = false, + read: usize = 0, + }; + /// `asker` is the connection the read came on, which only the listener /// knows. pub const Held = struct { asker: *anyopaque, req: Req, ticket: cloud9.fs.Ticket }; @@ -154,8 +180,9 @@ pub const Open = struct { gpa.free(run.output); gpa.free(run.line); }, - .event, .pty_data, .ctl => {}, + .event, .pty_data, .ctl, .lines => {}, } + o.pending.deinit(gpa); if (o.replay) |r| gpa.free(r.bytes); o.* = .{}; } @@ -191,6 +218,7 @@ pub const TopFile = enum(u5) { layout, tag, col, + tagexec, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); @@ -199,7 +227,7 @@ pub const TopFile = enum(u5) { pub fn mode(f: TopFile) u16 { return switch (f) { .root, .pane, .col => 0o755, - .look, .exec, .log, .focus, .ctl, .tag => 0o666, + .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666, .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444, }; } @@ -231,6 +259,9 @@ pub const PaneFile = enum(u5) { event, look, exec, + /// A word run as a click in the pane's tag, as /tagexec is in the + /// workspace's and col/<n>/exec in a column's. + tagexec, pty, pty_ctl, pty_status, @@ -468,10 +499,14 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .dir = c.file == .dir, .mode = switch (c.file) { .dir => 0o755, - .ctl, .exec => 0o222, - .tag => 0o666, + .ctl => 0o222, + .tag, .exec => 0o666, + }, + .size = switch (c.file) { + .tag => cols.headerText(p, c.serial).?.len + 1, + .exec => ctl.resultsLen(p), + else => 0, }, - .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0, .mtime = p.fs.started, .append = c.file == .tag, }; @@ -513,7 +548,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), - .look, .exec => ctl.resultsLen(p), + .look, .exec, .tagexec => ctl.resultsLen(p), .log => events.logLen(&p.fs.log), .listeners => listeners: { var buf: [screen.listeners_cap]u8 = undefined; @@ -618,7 +653,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { .root => { // The guide, then what describes the session, then the // files that act, then the directories. - inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); @@ -680,16 +715,26 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { // pane it made, so reading it twice answers the same one and // closing it leaves the pane alone. .new => { - const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots"); + const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane"); + p.fs.no_pane_slot = false; + // Refused, the open's err is the one record of it (the + // one rule): no msg besides. + p.fs.capturing = true; p.newScratchFor(p.active, .fs); + p.fs.capturing = false; + p.fs.failure_len = 0; const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); + // No room in its column: it closes at the step's end. + if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]); return .{ .tag = req.tag, .handle = made.serial }; }, .screen => .{ .snapshot = null }, .log => .{ .log = .{} }, - else => return .{ .tag = req.tag }, + else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag }, }, - .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT), + .col => |c| if (layout.columnBySerial(p, c.serial) == null) + return Reply.fail(req.tag, E.NOENT) + else if (linesOpen(target, req)) .lines else return .{ .tag = req.tag }, .pane => |t| what: { const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); @@ -706,7 +751,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { 1, 2 => .ctl, // OWRITE, ORDWR else => return .{ .tag = req.tag }, }, - else => return .{ .tag = req.tag }, + else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag }, }; }, }; @@ -715,6 +760,10 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { } else return Reply.fail(req.tag, E.NFILE); const o = &p.fs.opens[i]; o.* = .{ .node = req.node, .what = what }; + if (resultsFile(target)) { + @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]); + o.results.len = p.fs.results_len; + } switch (o.what) { // A screen freezes at open; a terminal body at its first read. .snapshot => |*bytes| if (pane_of == null) { @@ -734,7 +783,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { pn.fs.event_reader = pn.fs.event_reader or reader; }, .pty_data => pane_of.?.fs.pty_reader = true, - .run, .ctl => {}, + .run, .ctl, .lines => {}, } return .{ .tag = req.tag, .handle = @intCast(i + 1) }; } @@ -771,9 +820,21 @@ fn releaseHandle(p: *Pardes, req: Req) void { p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = false; }; + // `: > limit` clears it for good once its open goes. + if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| { + p.panes[id].?.fs.limit_truncated = null; + }; if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial); if (t == .top and t.top == .tag) cols.released(p, null); const o = openOf(p, req) orelse return; + // The last line an open wrote with no newline after it runs now. + if (o.pending.items.len > 0) { + const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch ""; + o.pending.clearRetainingCapacity(); + const w: Req = .{ .tag = req.tag, .op = .write, .node = req.node, .handle = req.handle, .data = rest }; + const reply = writeNow(p, w, t); + if (reply.status == .err) events.noteError(p, w, reply); + } const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null; if (of) |pn| switch (o.what) { .event => |reader| { @@ -795,7 +856,7 @@ fn releaseHandle(p: *Pardes, req: Req) void { .ctl => if (pn.fs.lock == req.handle) { pn.fs.lock = null; }, - .snapshot, .log => {}, + .snapshot, .log, .lines => {}, }; o.deinit(p.gpa); } @@ -861,7 +922,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { break :index stagedReply(p, req); }, .status => ctl.readStatus(p, req), - .look, .exec => ctl.readResults(p, req), + .look, .exec, .tagexec => ctl.readResults(p, req), .log => events.readLog(p, req), .screen => screen.readSnapshot(p, req, null), .listeners => screen.readListeners(p, req), @@ -892,25 +953,92 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { // An event or pty/data read the pane closed under: acme's // answer to the same (editors/acme/xfid.c:1005). if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data) - return failText(req.tag, E.IO, e_shut_down); + return failText(req.tag, E.NOENT, e_shut_down); return Reply.fail(req.tag, E.NOENT); }; const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); return pane.read(p, req, id, pn, t.file); }, - .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM), + .col => |c| return switch (c.file) { + .tag => cols.read(p, req, c.serial), + .exec => ctl.readResults(p, req), + else => Reply.fail(req.tag, E.PERM), + }, } } +/// The files whose writes are lines of commands, run as each line is whole. +fn linesFile(target: Target) bool { + return switch (target) { + .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec, + .col => |c| c.file == .ctl or c.file == .exec, + .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec, + }; +} + +/// The ones of those a read answers with the panes a write touched. +fn resultsFile(target: Target) bool { + return switch (target) { + .top => |f| f == .look or f == .exec or f == .tagexec, + .col => |c| c.file == .exec, + .pane => |t| t.file == .look or t.file == .exec or t.file == .tagexec, + }; +} + +/// An open that keeps lines or results: any that may write one of those +/// files, and any that may read a look or exec. +fn linesOpen(target: Target, req: Req) bool { + if (!linesFile(target)) return false; + return req.omode & 3 != 0 or resultsFile(target); +} + +/// A release that will run a line its open held back. +fn holdsLines(p: *Pardes, req: Req) bool { + const o = openOf(p, req) orelse return false; + return o.pending.items.len > 0; +} + +/// A line held back longer than this is refused rather than kept growing. +const pending_cap = 1 << 20; + fn write(p: *Pardes, req: Req, target: Target) Reply { + const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target); + if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target); + // A clicked line holds no control character: refused at once, not held + // to fail unseen at the close. + if (resultsFile(target)) for (req.data) |c| if (c < ' ' and c != '\t' and c != '\n' and c != '\r') return Reply.fail(req.tag, E.INVAL); + o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); + const end = ctl.completeEnd(p, o.pending.items); + if (end == 0) { + if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) }; + o.pending.clearRetainingCapacity(); + return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB"); + } + const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM); + const rest = o.pending.items.len - end; + std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]); + o.pending.shrinkRetainingCapacity(rest); + var w = req; + w.data = whole; + var reply = writeNow(p, w, target); + if (resultsFile(target)) { + @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]); + o.results = .{ .list = o.results.list, .len = p.fs.results_len, .wrote = true }; + } + if (reply.status == .ok) reply.written = @intCast(req.data.len); + return reply; +} + +fn writeNow(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { - .look, .exec => ctl.command(p, req, null, f == .exec), + .look, .exec => ctl.command(p, req, null, f == .exec, false), .log => events.writeLog(p, req), .focus => ctl.writeFocus(p, req), .ctl => ctl.writeRoot(p, req), .tag => cols.write(p, req, null), + .tagexec => cols.writeExec(p, req, null), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { @@ -1069,13 +1197,13 @@ test "readdir lists the root and a pane directory without creating anything" { @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); try testing.expect(top.len >= 10); - for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| { + for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 13, top[i].dir); + try testing.expectEqual(i >= 14, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node); - try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node); - try testing.expectEqual(fs.os_root, top[15].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node); + try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node); + try testing.expectEqual(fs.os_root, top[16].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); @@ -1114,10 +1242,10 @@ test "readdir lists the root and a pane directory without creating anything" { const dir = rdir(p, Node.of(serial, .dir), 0); const files = th.dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 17), files.len); + try testing.expectEqual(@as(usize, 18), files.len); for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata", - "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", + "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", "tagexec", }, 0..) |name, i| try testing.expectEqualStrings(name, files[i].name); try testing.expect(th.nameAt(files, ".") == null); @@ -1203,6 +1331,10 @@ test "opening /pane/new makes a pane and removing one closes it" { const id = p.paneBySerial(serial).?; try testing.expectEqualStrings("", p.panes[id].?.file.?.content); + // Its directory is the session's, not that of /test.txt, the pane + // that happened to have the keyboard. + var named: [4096]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&named, "{s}/+New", .{p.sessionDir()}), p.panes[id].?.file.?.path); _ = wr(p, Node.of(serial, .body), "hi"); // A second open is a second pane, and releasing a clone fid leaves the @@ -1310,14 +1442,19 @@ test "editor paths resolve to the same nodes the wire serves" { test "at the pane cap, pane/new, look and New each say so, and look reads back empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); + // Tall enough that the column holds every pane at its tag and two rows, + // and wide enough that each tag, the session directory's +New, is one. + p.update(.{ .resize = .{ .cols = 400, .rows = pardes.MAX_PANES * 4 } }); const new = @intFromEnum(TopFile.new); while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status); var want: [64]u8 = undefined; - const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES}); + const said = try std.fmt.bufPrint(&want, "no space for a pane: {d} max", .{pardes.MAX_PANES}); const refused = call(p, .{ .tag = 2, .op = .open, .node = new }); - try testing.expectEqual(E.NFILE, refused.errno()); + try testing.expectEqual(E.NOSPC, refused.errno()); try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said)); try testing.expect(th.logHas(p, said)); + // Its err alone says it: no msg besides (the one rule). + try testing.expect(!th.logHas(p, "New: no space")); // A look that would open a pane fails its write, and says the same. var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1335,3 +1472,113 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e const made = th.wr(p, ctl_node, "New\n"); try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null); } + +test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const exec = @intFromEnum(TopFile.exec); + const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle; + try testing.expect(h != 0); + for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece| + try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status); + try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n")); + try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n")); + _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true }); + try testing.expect(th.logHas(p, " tail\n")); + // An Edit block's text lines come in later writes on the same open. + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const c = call(p, .{ .tag = 4, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; + for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece| + try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status); + _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true }); + try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content); +} + +test "look and exec read back what their own open's write touched; a fresh open reads the session's last" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const exec = @intFromEnum(TopFile.exec); + const first = p.panes[0].?.serial; + const mine = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle; + const theirs = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 2 }).reply.handle; + _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = mine, .data = "Tty\n" }); + const made = p.panes[p.active].?.serial; + try testing.expect(made != first); + _ = call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = theirs, .data = "Msg hi\n" }); + var want: [16]u8 = undefined; + // Read at the offset the write left, as a shell's `exec 3<>` does. + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), call(p, .{ .tag = 5, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes); + try testing.expectEqualStrings("", call(p, .{ .tag = 6, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes); + const said = p.panes[p.active].?.serial; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 7, .op = .read, .node = exec, .handle = theirs, .size = 64 }).bytes); + const fresh = call(p, .{ .tag = 8, .op = .open, .node = exec }).reply.handle; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes); + for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true }); +} + +test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const new = @intFromEnum(TopFile.new); + var made: usize = 1; + const refused = while (made < pardes.MAX_PANES) : (made += 1) { + const a = call(p, .{ .tag = 1, .op = .open, .node = new }); + if (a.reply.status == .err) break a; + } else return error.NeverRefused; + try testing.expectEqual(E.NOSPC, refused.errno()); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null); + p.sync(); + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + try testing.expectEqual(made, count); // the refused one is gone + for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| { + if (p.col_n[c] > 1 and !p.panes[k].?.collapsed) + try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows); + }; +} + +test "an Edit block still open when its open closes fails there with an err and changes nothing" { + const p = try th.withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const ctl_node = Node.of(serialOf(p), .ctl); + const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" }); + _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true }); + try testing.expect(th.logHas(p, "unmatched")); + try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); +} + +test "an a, c or i text still open when its open closes fails there too and changes nothing" { + const p = try th.withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const ctl_node = Node.of(serialOf(p), .ctl); + const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" }); + _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true }); + try testing.expect(th.logHas(p, "not ended by a . line")); + try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); +} + +test "an a, c or i text cut short through exec is an err in the log, not a msg" { + const p = try th.withFile(testing.allocator, "abc\n"); + defer p.deinit(); + const exec = Node.of(serialOf(p), .exec); + const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle; + _ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" }); + _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true }); + var want: [64]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)}))); + try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i")); + try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content); +} + +test "a pane made from pane/new goes last in its column, even when the last pane has no room to halve" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + for (0..3) |_| { + const made = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }).reply.handle; + p.sync(); + try testing.expectEqual(made, p.panes[p.col_panes[0][p.col_n[0] - 1]].?.serial); + } +} |
