diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/addr.zig | 56 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 252 | ||||
| -rw-r--r-- | src/ninep/events.zig | 33 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 73 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 101 | ||||
| -rw-r--r-- | src/ninep/testing.zig | 9 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 56 |
7 files changed, 520 insertions, 60 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index 0537c17a..1e47183b 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -91,6 +91,26 @@ pub const Addr = struct { if (d < '0' or d > '9') break; n = @min(n * 10 + (d - '0'), std.math.maxInt(u32)); } + // pardes's `line:col`, Look's `file:12:5` spelling (look.zig + // parsePathLine): the point at that column of that line, the + // column in bytes from 1, clamped to the line's end and snapped + // to a character's start as Look's cursor is. Not sam's. + if (size == .line and dir == 0 and a.i + 1 < a.expr.len and a.expr[a.i] == ':' and + a.expr[a.i + 1] >= '0' and a.expr[a.i + 1] <= '9') + { + a.i += 1; + var col: u64 = 0; + while (a.i < a.expr.len and a.expr[a.i] >= '0' and a.expr[a.i] <= '9') : (a.i += 1) + col = @min(col * 10 + (a.expr[a.i] - '0'), std.math.maxInt(u32)); + const lines = modal.cursorLineCount(a.text); + if (n == 0 or n > lines or col == 0) { + a.err = e_range; + return null; + } + const q = clip(modal.offsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) })); + r = .{ .q0 = q, .q1 = q }; + continue; + } r = a.number(r, @intCast(n), dir, size) orelse return null; dir = 0; size = .line; @@ -202,8 +222,8 @@ pub const Addr = struct { a.err = "no previous regular expression"; return null; } - var rx = regexp_.Regex.compile(pat) catch { - a.err = e_regexp; + var rx = regexp_.Regex.compile(pat) catch |err| { + a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp; return null; }; const found = if (back) found: { @@ -340,13 +360,34 @@ test "a failed address leaves none, so data refuses rather than act at the last try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, addr, 0, 64).reply.ename); try testing.expectEqual(E.INVAL, th.call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .xdata), .truncate = true }).errno()); try testing.expectEqualStrings("one\ntwo\n", p.panes[0].?.file.?.content); - // A good address, or a truncated addr, gives it one again. + // A good address gives it one again; truncating addr does not. _ = th.wr(p, addr, "#0,#3"); try testing.expectEqual(Status.ok, th.wr(p, data, "ONE").reply.status); try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); _ = th.wr(p, addr, "99"); _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = addr, .truncate = true }); - try testing.expectEqual(Status.ok, th.wr(p, data, "0").reply.status); + try testing.expectEqualStrings(pane_files.e_addr_failed, th.wr(p, data, "0").reply.ename); +} + +test "a shell's > on addr searches on from the last address, and 0 starts over" { + const p = try th.withFile(testing.allocator, "foo x foo y foo\n"); + defer p.deinit(); + const serial = th.serialOf(p); + const addr = Node.of(serial, .addr); + const data = Node.of(serial, .data); + // echo /foo/ > addr; printf foobar > data -- twice over, as a loop does. + for (0..3) |_| { + _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = addr, .truncate = true }); + try testing.expectEqual(Status.ok, th.wr(p, addr, "/foo/").reply.status); + _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = data, .truncate = true }); + try testing.expectEqual(Status.ok, th.wr(p, data, "foobar").reply.status); + } + try testing.expectEqualStrings("foobar x foobar y foobar\n", p.panes[0].?.file.?.content); + // .+#1 moves on from where the last write left the address. + _ = th.wr(p, addr, "0"); + _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = addr, .truncate = true }); + try testing.expectEqual(Status.ok, th.wr(p, addr, ".+#1").reply.status); + try testing.expectEqualStrings(" 1 1 ", th.rd(p, addr, 0, 64).bytes); } test "the address language, form by form" { @@ -372,6 +413,11 @@ test "the address language, form by form" { .{ .expr = "/two/", .q0 = 4, .q1 = 7 }, .{ .expr = "/t.o/", .q0 = 4, .q1 = 7 }, .{ .expr = "1\n", .q0 = 0, .q1 = 4 }, + // pardes's line:col, composing like any simple address. + .{ .expr = "2:2", .q0 = 5, .q1 = 5 }, + .{ .expr = "2:99", .q0 = 7, .q1 = 7 }, + .{ .expr = "2:2,3:1", .q0 = 5, .q1 = 8 }, + .{ .expr = "2:2+#3", .q0 = 8, .q1 = 8 }, }) |c| { _ = th.wr(p, addr, "#0"); const w = th.wr(p, addr, c.expr); @@ -420,7 +466,7 @@ test "the address language, form by form" { for ([_][2][]const u8{ .{ "zzz", e_syntax }, .{ "/nomatch/", e_no_match }, .{ "99", e_range }, .{ "#999", e_range }, .{ "/a[/", e_regexp }, .{ "/(a/", e_regexp }, - .{ "/*a/", e_regexp }, + .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_range }, }) |c| { _ = th.wr(p, addr, "#0"); try testing.expectEqualStrings(c[1], th.wr(p, addr, c[0]).reply.ename); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index b28e02fa..b4144fd2 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -69,13 +69,19 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { const before = Serials.snapshot(p); // An exec answers the pane it went to: a command pane it reused, or // the terminal it typed into. + p.look_missed = false; const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: { look.lookAt(p, id, text); break :blk null; }; + // A look that found nothing answers nothing, not the pane before it. + if (!exec and p.look_missed) return; 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) { + // A look's hits buffer is how it found the text, not where: it + // answers the pane the hit is selected in. + if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue; noteResult(p, serial); created = true; }; @@ -84,6 +90,37 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial); } +/// A write's messages, a line each, except that an `Edit` line takes the +/// lines after it while its `{` group or `a`/`c`/`i` text block is open +/// (sam_edit.needsMore): a block written whole reaches Edit whole, never +/// as lines of their own. +const Messages = struct { + p: *Pardes, + data: []const u8, + at: usize = 0, + + fn init(p: *Pardes, data: []const u8) Messages { + return .{ .p = p, .data = data }; + } + + fn next(m: *Messages) ?[]const u8 { + if (m.at >= m.data.len) return null; + const start = m.at; + var end = std.mem.indexOfScalarPos(u8, m.data, start, '\n') orelse m.data.len; + m.at = end + 1; + const first = std.mem.trim(u8, m.data[start..end], " \t\r"); + const edit = "Edit"; + if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t')) + return first; + const sam = @import("../sam_edit.zig"); + while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) { + end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len; + m.at = end + 1; + } + return std.mem.trim(u8, m.data[start..end], " \t\r"); + } +}; + // ---- look and exec ---- /// One command per line, checked whole before any line runs: a line is the @@ -93,11 +130,11 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { 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"); + var it = Messages.init(p, req.data); + while (it.next()) |text| { if (text.len == 0) continue; - for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL); + // Only an Edit block holds newlines (Messages). + for (text) |c| if (c < ' ' and c != '\t' and c != '\n') 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); @@ -205,6 +242,13 @@ fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { return tree.failText(req.tag, E.INVAL, text); } +/// `refuse`, saying which ctl takes the message instead. +fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { + const room = p.fs.ename.len -| (why.len + ctl.len + 18); + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, line[0..@min(line.len, room)], ctl }) 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 @@ -216,8 +260,9 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? 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); + // Edit is the one pane word the root takes too: at the active pane. + if (builtins.registry.scope(b) != scope and b != .Edit) + return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl"); 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; @@ -298,6 +343,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .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 }), + .choice => |which| switch (which) { + inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }), + }, .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}), @@ -336,15 +384,16 @@ pub fn readRoot(p: *Pardes, req: Req) Reply { 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"); + var it = Messages.init(p, req.data); + while (it.next()) |line| { 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); + p.fs.session_write = true; + defer p.fs.session_write = false; if (runBuiltin(p, req, p.active, line)) |refusal| return refusal; } } @@ -445,25 +494,33 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { 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"); + var it = Messages.init(p, req.data); + while (it.next()) |line| { 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; + if (getRefused(p, pane)) |said| return tree.failText(req.tag, E.IO, said); 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); + if (other) return tree.failText(req.tag, E.BUSY, e_locked); 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 (std.mem.startsWith(u8, line, "answer ")) { + // The pane's question (its `ask` in the log), answered. + const choice = std.mem.trim(u8, line["answer ".len..], " "); + if (choice.len != 1) return refuse(p, req, "answer takes one choice, or -", line); + const id = p.paneBySerial(serial).?; + if (p.panes[id].?.prompt != .del_side and p.panes[id].?.prompt != .repl_choice) + return refuse(p, req, "no question asked", line); + if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]); } else if (!apply) { if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal; } else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| { @@ -475,6 +532,23 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { } const e_not_locked = "window not locked by this open"; +/// Who holds it is another open of this ctl, whose client pardes cannot name. +const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock"; + +/// acme's get asks winclean first (exec.c:513): unsaved text is warned +/// about once, and the same get again, nothing edited since, discards it. +fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { + const f = pane_files.fileOf(pane) orelse return null; + if (!panes.Output.fileTraits(f.output).saves or f.revision == f.saved_revision) return null; + // Dirty by a rename alone is no text to lose: only edits are asked about. + const saved = f.saved_hash orelse return null; + if (std.hash.Wyhash.hash(0, f.content) == saved) return null; + if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null; + pane.discard_warned = .{ .revision = f.revision, .by = .get }; + const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; + p.setMessage(p.paneBySerial(pane.serial).?, said); + return said; +} fn get(p: *Pardes, pane: *Pane) u16 { const f = pane_files.fileOf(pane) orelse return 0; @@ -486,6 +560,7 @@ fn get(p: *Pardes, pane: *Pane) u16 { panes.File.pushUndo(p, pane); panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; + f.saved_hash = std.hash.Wyhash.hash(0, f.content); return 0; } @@ -546,10 +621,23 @@ test "commands lists every builtin with its argument and the ctl that takes it" 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| + for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root\n", "\nUndo pane\n", "\nRedo pane\n" }) |row| try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null); } +test "Undo and Redo on a pane's ctl step its body through its edits" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + try testing.expectEqual(Status.ok, wr(p, body, "two\n").reply.status); + try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status); + try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes); +} + 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(); @@ -604,8 +692,8 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r 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("not a window control message \"Newcol\": write it to /ctl", wr(p, ctl_node, "Newcol").reply.ename); + try testing.expectEqualStrings("not a window control message \"Verbose off\": write it to /ctl", 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()); @@ -657,7 +745,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi 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("not a session control message \"Del\": write it to pane/<n>/ctl", 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; @@ -700,7 +788,7 @@ test "a second lock is refused until the holder unlocks or closes, and binds nob 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.expectEqualStrings(e_locked, 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); @@ -730,6 +818,112 @@ test "a second lock is refused until the holder unlocks or closes, and binds nob for (p.fs.opens) |o| try testing.expect(o.node == 0); } +test "Edit through a pane's ctl renames every foo in one undo step, and a failed one changes nothing" { + const p = try withFile(testing.allocator, "foo x foo y foo\n"); + defer p.deinit(); + const serial = serialOf(p); + const ctl_node = Node.of(serial, .ctl); + const body = Node.of(serial, .body); + try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,x/foo/c/foobar/\n").reply.status); + try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes); + const failed = wr(p, ctl_node, "Edit ,s/nothing/x/\n"); + try testing.expectEqual(E.IO, failed.errno()); + try testing.expectStringStartsWith(failed.reply.ename, "Edit: no substitution"); + try testing.expectEqualStrings("foobar x foobar y foobar\n", rd(p, body, 0, 64).bytes); + // The whole rename is one undo step. + try testing.expectEqual(Status.ok, wr(p, ctl_node, "Undo").reply.status); + try testing.expectEqualStrings("foo x foo y foo\n", rd(p, body, 0, 64).bytes); +} + +test "a write of an Edit block carries it whole, to a pane's ctl, the root's ctl and exec" { + const p = try withFile(testing.allocator, "foo x\nfoo y\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\ni/[/\na/]/\n}\n").reply.status); + try testing.expectEqualStrings("[foo] x\n[foo] y\n", rd(p, body, 0, 64).bytes); + p.active = p.paneBySerial(serial).?; + try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Edit ,x/\\[/{\nc/</\n}\n").reply.status); + try testing.expectEqualStrings("<foo] x\n<foo] y\n", rd(p, body, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Edit $a\nend\n.\n").reply.status); + try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes); + // An unclosed group is refused, and nothing after it runs as a line. + try testing.expectEqual(E.IO, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\nd\n").errno()); + try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes); +} + +test "dirty goes back to 0 when the text is the saved text again" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const dirty = Node.of(serial, .dirty); + try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "two\n").reply.status); + try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status); + try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes); +} + +test "a message from a root ctl write is the session's in the log" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.update(.tick); // the pane's `new` is logged before the log is opened + while (p.nextEffect()) |_| {} + const log = @intFromEnum(tree.TopFile.log); + const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len; + _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); + try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Kill\n").reply.status); + // The builtin's name, then what it said: both the session's. + try testing.expectEqualStrings("msg - Kill\n", call(p, .{ .tag = 4, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes); + try testing.expectEqualStrings("msg - Kill: nothing running\n", call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes); +} + +test "a look takes acme's :addr and file:addr, and a miss changes nothing" { + const gpa = testing.allocator; + const p = try withFile(gpa, "foo x foo y\nbar\n"); + defer p.deinit(); + const serial = serialOf(p); + const look_node = Node.of(serial, .look); + const dot = Node.of(serial, .dot); + // :addr is an address in this pane, evaluated from its dot. + try testing.expectEqual(Status.ok, wr(p, look_node, ":/y/\n").reply.status); + try testing.expectEqualStrings(" 10 11 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, look_node, ":#4\n").reply.status); + try testing.expectEqualStrings(" 4 4 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, look_node, ":/foo/\n").reply.status); // from #4: the second foo + try testing.expectEqualStrings(" 6 9 ", rd(p, dot, 0, 64).bytes); + // file:addr names the pane's own file here. + var line: [256]u8 = undefined; + const path = p.panes[0].?.file.?.path; + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:/bar/\n", .{path})).reply.status); + try testing.expectEqualStrings(" 12 15 ", rd(p, dot, 0, 64).bytes); + // A miss: no buffer, the selection kept, and it says so. + const panes_before = p.panes.len - std.mem.count(?*pardes.Pane, &p.panes, &.{null}); + try testing.expectEqual(Status.ok, wr(p, look_node, "zzqq\n").reply.status); + try testing.expectEqual(panes_before, p.panes.len - std.mem.count(?*pardes.Pane, &p.panes, &.{null})); + try testing.expectEqualStrings(" 12 15 ", rd(p, dot, 0, 64).bytes); + try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: no match"); + // A hit answers the pane the text is selected in, not its hits buffer. + try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status); + var own: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&own, "{d}\n", .{serial}), rd(p, look_node, 0, 64).bytes); + // A pattern with a blank in it; 0/re/ searches from the top, whatever dot is. + try testing.expectEqual(Status.ok, wr(p, look_node, ":/foo y/\n").reply.status); + try testing.expectEqualStrings(" 6 11 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:0/foo/\n", .{path})).reply.status); + try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes); + // file:N selects the line; a line past the end moves nothing and reads back empty. + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:2\n", .{path})).reply.status); + try testing.expectEqualStrings(" 12 16 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:99\n", .{path})).reply.status); + try testing.expectEqualStrings(" 12 16 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqualStrings("", rd(p, look_node, 0, 64).bytes); + try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: "); +} + 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"); @@ -886,6 +1080,10 @@ test "get reloads the pane from disk and reports a missing file without losing d _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); _ = wr(p, Node.of(serial, .body), "unsaved\n"); + // Unsaved text is asked about once, as acme's get asks winclean. + const asked = wr(p, ctl_node, "get\n"); + try testing.expectEqual(E.IO, asked.errno()); + try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)")); 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); @@ -999,7 +1197,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" { try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null); } -test "a look that fills a column's stillborn scratch is answered with the scratch already gone" { +test "a look after Newcol fills the empty column the keyboard is on" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); @@ -1009,14 +1207,12 @@ test "a look that fills a column's stillborn scratch is answered with the scratc var dir: [4096]u8 = undefined; const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; var line: [4200]u8 = undefined; - // Newcol makes a column of one empty +New; a file looked from it - // joins that column and takes the scratch's place. + // Newcol makes an empty column and gives its tag the keyboard; no pane + // has it, so focus reads empty. try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); - const scratch = p.panes[p.active].?.serial; - try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); - try testing.expect(p.paneBySerial(scratch) == null); - var want: [32]u8 = undefined; - const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch}); - var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n'); - while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone)); + try testing.expectEqual(@as(usize, 0), p.col_n[p.ncol - 1]); + try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.focus), 0, 64).bytes); + try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); + try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]); + try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } diff --git a/src/ninep/events.zig b/src/ninep/events.zig index ffdea95b..0e489be7 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -565,6 +565,32 @@ const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; +test "a stream read at an offset inside its last answer gets the rest of it again, as bash's read seeks back" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + p.update(.tick); + while (p.nextEffect()) |_| {} + const log = @intFromEnum(tree.TopFile.log); + const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + const frozen: u64 = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len; + _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); + notePath(p, "dump", "/a"); + notePath(p, "dump", "/b"); + const Read = struct { + fn at(pp: *Pardes, h: u32, off: u64) []const u8 { + return call(pp, .{ .tag = 4, .op = .read, .node = @intFromEnum(tree.TopFile.log), .handle = h, .off = off, .size = 128 }).bytes; + } + }; + try testing.expectEqualStrings("dump /a\n", Read.at(p, f, frozen)); + // bash kept "dump" and seeked back to just past it. + try testing.expectEqualStrings(" /a\n", Read.at(p, f, frozen + 4)); + // At the end of what it was given: the next record. + try testing.expectEqualStrings("dump /b\n", Read.at(p, f, frozen + 8)); + // A client that reads every record at one offset still gets new ones. + notePath(p, "dump", "/c"); + try testing.expectEqualStrings("dump /c\n", Read.at(p, f, frozen + 8)); +} + test "event records are acme's bytes, one per read, and .again when empty" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg fs-ran\n"); @@ -908,8 +934,13 @@ test "a Dump written and a Restore made are in the log, with their files" { const log = try freezeLog(core); defer core.gpa.free(log.bytes); var want: [64]u8 = undefined; - try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what}))); + try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what})) != null); } + // The replacement's last says which serial each pane had. + const log = try freezeLog(restored); + defer restored.gpa.free(log.bytes); + var want: [64]u8 = undefined; + try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial }))); } test "opens of the log share the open records, and a closed one frees its record" { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index d2b2a791..13381066 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -29,14 +29,15 @@ const Node = tree.Node; /// Filesystem state a pane carries beside its editor state. pub const State = struct { /// The range `data` and `xdata` read and write through. acme clears it - /// when the first client opens `addr` (editors/acme/xfid.c:105), which - /// suits a client that holds the fid open and leaves a shell reading back - /// `0 0` from the address it just wrote. Here it is the pane's own - /// register, cleared by truncating the file, so that `cp addr dot` and + /// when the first client opens `addr` (editors/acme/xfid.c:105-108), + /// which suits a client that holds the fid open; a shell cannot, and + /// would evaluate every `echo /re/ > addr` from #0 and never advance. + /// Here it is the pane's own register, cleared by neither an open nor + /// a truncation: `0` or `,` starts over, and `cp addr dot` and /// `cat addr` answer what was written. addr: Range = .{}, /// The last address written to `addr` failed, so there is none: `data` - /// and `xdata` refuse until one is written or `addr` is truncated, + /// and `xdata` refuse until a good one is written, /// rather than act at the address before it, which acme would do. addr_failed: bool = false, limit: ?Range = null, @@ -46,6 +47,10 @@ pub const State = struct { event_reader: bool = false, events: events.Queue = .{}, nomark: bool = false, + /// The revision a truncation of `data`, `xdata` or `body` left: the + /// write that follows it at once joins its undo step, so a shell's + /// `printf x > data` is one step, as acme's replace is. + joined: ?u32 = null, noscroll: bool = false, tag_snap: std.ArrayList(u8) = .empty, /// An open reads pty/data, so output queues for it; a second is refused. @@ -58,6 +63,9 @@ pub const State = struct { lock: ?u32 = null, /// The host started a shell it could not teach to mark its prompts. unmarked: bool = false, + /// The pty's size as pty/ctl's winsize last set it, until the pane's own + /// grid resizes and gives the pty its size again. + winsize: ?[2]u16 = null, /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, @@ -189,7 +197,9 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const @memcpy(new[0..lo], f.content[0..lo]); @memcpy(new[lo..][0..take], bytes[0..take]); @memcpy(new[lo + take ..], f.content[hi..]); - if (!pane.fs.nomark) panes.File.pushUndo(p, pane); + const join = pane.fs.joined == f.revision; + pane.fs.joined = null; + if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane); panes.File.setContent(p, f, new); return take; } @@ -507,7 +517,7 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { const word = std.mem.trim(u8, req.data, " \t\r\n"); - if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "takes 1 or 0"); + if (!std.mem.eql(u8, word, "1") and !std.mem.eql(u8, word, "0")) return tree.failText(req.tag, E.INVAL, "bad flag: takes 1 or 0"); const on = word[0] == '1'; const pf = &pane.fs; switch (file) { @@ -515,9 +525,12 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL); f.saved_revision = if (on) f.revision -% 1 else f.revision; }, + // acme's nomark joins the writes after it into one undo step. The + // step starts here: the text as it is now is what Undo brings back, + // and turning mark on again only ends it. .mark => { + if (!on and !pf.nomark) panes.File.pushUndo(p, pane); pf.nomark = !on; - if (on) panes.File.pushUndo(p, pane); }, .scroll => pf.noscroll = !on, else => unreachable, @@ -592,11 +605,11 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { } const free = p.freeSlot() orelse return null; const content = p.gpa.dupe(u8, text) catch return null; - const np = panes.Output.open(p, free, dir, .errors, "", content) catch { + _ = panes.Output.open(p, free, dir, .errors, "", content) catch { p.gpa.free(content); return null; }; - exec.placeDoc(p, id, free, np); + exec.placeNew(p, id, id, free, .errors); return text.len; } @@ -610,6 +623,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { switch (file) { .body => if (fileOf(pane) != null) { _ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err; + pf.joined = fileOf(pane).?.revision; pf.addr = .{}; setDot(pane, .{}); }, @@ -619,6 +633,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { const q1 = @max(q0, pf.addr.q1); const before = dotOf(pane); _ = spliceBody(p, pane, q0, q1, "") orelse return .err; + pf.joined = fileOf(pane).?.revision; setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) }); pf.addr = .{ .q0 = q0, .q1 = q0 }; }, @@ -634,10 +649,10 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { pane.tag.vsel.active = false; pane.tag.nsel = 0; }, - .addr => { - pf.addr = .{}; - pf.addr_failed = false; - }, + // A shell's `>` truncates before it writes: the address written + // is evaluated from where the last one left off, as with `>>`. + // `0` (or `,`) is how to start over. See State.addr. + .addr => {}, .limit => pf.limit = null, .dot => if (fileOf(pane) != null) setDot(pane, .{}), else => {}, @@ -875,7 +890,7 @@ test "the flag files read and write the state acme kept behind ctl verbs" { try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| { try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); - try testing.expectEqualStrings("takes 1 or 0", wr(p, node, bad).reply.ename); + try testing.expectEqualStrings("bad flag: takes 1 or 0", wr(p, node, bad).reply.ename); } } @@ -905,6 +920,34 @@ test "the flag files read and write the state acme kept behind ctl verbs" { try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno()); } +test "a replace through data, truncate then write, is one undo step" { + const p = try withFile(testing.allocator, "one two\n"); + defer p.deinit(); + const serial = serialOf(p); + _ = wr(p, Node.of(serial, .addr), "/two/"); + _ = call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .data), .truncate = true }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "2").reply.status); + try testing.expectEqualStrings("one 2\n", rd(p, Node.of(serial, .body), 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one two\n", rd(p, Node.of(serial, .body), 0, 64).bytes); +} + +test "writes under mark 0 are one undo step, even with no history before them" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const serial = serialOf(p); + const body = Node.of(serial, .body); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "0").reply.status); + _ = wr(p, body, "two\n"); + _ = wr(p, body, "three\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "1").reply.status); + try testing.expectEqualStrings("one\ntwo\nthree\n", rd(p, body, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status); + try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status); + try testing.expectEqualStrings("one\ntwo\nthree\n", rd(p, body, 0, 64).bytes); +} + test "truncating the tag clears its editable tail" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 3599b612..393516ce 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -60,6 +60,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { if (words.next() != null) return false; if (!apply) return true; p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); + pane.fs.winsize = .{ cols, rows }; }, .sig => { const which = signalNamed(words.next() orelse return false) orelse return false; @@ -79,13 +80,20 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { /// ctl reads back its state in the words it takes, as a Plan 9 ctl does. pub fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply { - p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch + const size = winsize(pane); + p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ size[0], size[1] }) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } -/// Three right-aligned fields: cols, rows and busy: whether a run written now -/// would be told `busy` (a command runs, or text is typed at the prompt). +/// The pty's size: what ctl's winsize set, else the pane's grid. +fn winsize(pane: *const Pane) [2]u16 { + return pane.fs.winsize orelse .{ pane.cols, pane.rows }; +} + +/// Three right-aligned fields: the pty's cols and rows, and busy: whether a +/// run written now would be told `busy` (a command runs, or text is typed at +/// the prompt). A newline ends the line, as a Plan 9 status file's does. pub const status_len: u64 = 36; pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { @@ -97,7 +105,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, @intFromBool(busy) }) catch {}; + const size = winsize(pane); + out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {}; return tree.stagedReply(p, req); } @@ -287,6 +296,12 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { const done = marks.finished orelse return; if (done.seq != slot.want) return; pf.run = null; + finish(p, slot, pane, done.status); +} + +/// Answers a run whose command ended with `status`: the header and the +/// output it printed. +fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void { const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch return answer(p, slot, "error out of memory", .{}); defer p.gpa.free(printed.text); @@ -305,7 +320,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { } // A D that carries no status says nothing of how the command went. var code: [16]u8 = undefined; - const status = if (done.status) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?"; + const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?"; // The header is the whole first line, so a count there can never be // mistaken for output; `cut` with no count: its start is not there to // count from. @@ -317,6 +332,22 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { answer(p, slot, "exit {s}", .{status}); } +/// The shell itself exited (a run's `exit 3`, or one typed): a run waiting +/// on the command it ran answers with the shell's status and what it +/// printed, and the log says `exit` before the pane's `del`. Unknown, the +/// status is not guessed: the run hears `error shell gone` when the pane +/// goes. +pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void { + const code = status orelse return; + if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) { + pane.fs.run = null; + pardes.panes.Terminal.endOutputHere(pane); + finish(p, slot, pane, code); + }; + var buf: [4]u8 = undefined; + pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable); +} + /// The pane closed or its shell was replaced: the command's end will never /// be reported, so the run says so instead of waiting forever. A line not /// sent yet waits on for the replacement's first prompt. @@ -466,6 +497,12 @@ test "every pty/ctl verb, and every refusal" { try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows); try testing.expectEqual(cols, pane.cols); try testing.expectEqual(rows, pane.rows); + // ctl and status read back the pty's size, until the pane resizes. + try testing.expectEqualStrings("winsize 132 44\n", rd(p, ctl, 0, 64).bytes); + try testing.expect(std.mem.startsWith(u8, rd(p, Node.of(serialOf(p), .pty_status), 0, 64).bytes, " 132 44 ")); + p.update(.{ .resize = .{ .cols = 76, .rows = 24 } }); + try testing.expect(pane.cols != 132); + try testing.expectEqualStrings(try std.fmt.bufPrint(&said, "winsize {d} {d}\n", .{ pane.cols, pane.rows }), rd(p, ctl, 0, 64).bytes); for ([_]struct { line: []const u8, want: pardes.PtySignal }{ .{ .line = "sig INT", .want = .int }, @@ -527,6 +564,8 @@ test "owned cwd pty exec rejects long paths before applying its batch" { const p = try withTerm(testing.allocator); defer p.deinit(); const pane = p.panes[0].?; + // A one-row tag: the long path would wrap it onto rows and resize the pty. + pane.tag_expanded = false; const ctl = Node.of(pane.serial, .pty_ctl); var path: [1025]u8 = @splat('d'); path[0] = '/'; @@ -554,7 +593,7 @@ test "pty/status reports the grid and who holds the tty" { const a = rd(p, status, 0, 256); try testing.expectEqual(Status.ok, a.reply.status); var want: [64]u8 = undefined; - const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 }); + const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 0 }); try testing.expectEqualStrings(whole, a.bytes); try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len); try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes); @@ -563,7 +602,7 @@ test "pty/status reports the grid and who holds the tty" { p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable }; const held = rd(p, status, 0, 256); try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }), + try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 1 }), held.bytes, ); } @@ -645,12 +684,12 @@ test "run answers how its command ended once the shell is back at a prompt" { // Before its first prompt the shell cannot take a line yet: the line // waits for it, and pty/status does not call the shell busy meanwhile. const status = Node.of(serialOf(p), .pty_status); - try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0 ")); + try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0\n")); const early = call(p, .{ .tag = 1, .op = .open, .node = node }); const waiting = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" }); try testing.expectEqualStrings("", waiting.pty()); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status); - try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1 ")); + try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1\n")); // A respawn before that prompt keeps it waiting for the new shell's. p.acknowledgeShell(0, "/bin/sh", true); try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status); @@ -746,8 +785,52 @@ test "run answers how its command ended once the shell is back at a prompt" { _ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" }); try p.removePane(0, null); try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes); + // It still stats while the run is open (a reader's fstat), on the + // open or on the file's name, and not once it is released. + try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node, .handle = gone.reply.handle }).reply.status); + try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node }).reply.status); _ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle }); for (p.fs.opens) |o| try testing.expect(o.node == 0); + try testing.expectEqual(E.NOENT, call(p, .{ .tag = 21, .op = .getattr, .node = node }).errno()); +} + +test "a shell that exits under a run answers its status, and the log says exit before del" { + const gpa = testing.allocator; + const p = try withTerm(gpa); + defer p.deinit(); + const serial = serialOf(p); + const node = Node.of(serial, .pty_run); + const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07"; + p.update(.{ .output = .{ .pane = 0, .bytes = prompt } }); + while (p.nextEffect()) |_| {} + const log = @intFromEnum(tree.TopFile.log); + const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len; + _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" }); + + const r = call(p, .{ .tag = 4, .op = .open, .node = node }); + _ = call(p, .{ .tag = 5, .op = .write, .node = node, .handle = r.reply.handle, .data = "echo bye; exit 3\n" }); + p.update(.{ .output = .{ .pane = 0, .bytes = "echo bye; exit 3\r\n\x1b]133;C;aid=pardes\x07bye\r\n" } }); + p.update(.{ .exited = .{ .pane = 0, .status = 3 } }); + p.update(.{ .eof = .{ .pane = 0 } }); + while (p.nextEffect()) |_| {} + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqualStrings("exit 3\nbye\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = r.reply.handle, .size = 64 }).bytes); + var want: [128]u8 = undefined; + // A followed log reads a record at a time: the exit, then the del. + var records: [2][]const u8 = undefined; + var at = frozen; + for (&records) |*rec| { + rec.* = try gpa.dupe(u8, call(p, .{ .tag = 7, .op = .read, .node = log, .handle = f, .off = at, .size = 4096 }).bytes); + at += rec.len; + } + defer for (records) |rec| gpa.free(rec); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "exit {d} 3\n", .{serial}), records[0]); + try testing.expect(std.mem.startsWith(u8, records[1], try std.fmt.bufPrint(&want, "del {d} ", .{serial}))); + // A write to its pty/data now finds no pane. + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_data), "x").errno()); + _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = r.reply.handle }); + _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = f }); } test "run on a command pane says no shell is there" { diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index fce77f10..927552e2 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -56,6 +56,15 @@ pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer { return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size }); } +/// Whether the log, read whole from a fresh open, holds `needle`. +pub fn logHas(p: *Pardes, needle: []const u8) bool { + const log = @intFromEnum(tree.TopFile.log); + const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + defer _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); + const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; + return std.mem.indexOf(u8, text, needle) != null; +} + pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer { return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data }); } diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index f118e4b4..ad95f6bf 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -130,6 +130,13 @@ pub const Open = struct { /// place of the older. The listener fills it in and answers it /// (src/9p_io.zig, `answerHeld`). held: ?Held = null, + /// The last answer a stream open (a followed log, event, pty/data) gave, + /// and the offset it was read at. A read at an offset inside it is + /// answered from it again: bash's `read` takes a chunk, keeps a line and + /// lseeks back to just past it, and a stream has already moved on. + replay: ?Replay = null, + + pub const Replay = struct { off: u64, bytes: []u8 }; /// `asker` is the connection the read came on, which only the listener /// knows. @@ -145,6 +152,7 @@ pub const Open = struct { }, .event, .pty_data, .ctl => {}, } + if (o.replay) |r| gpa.free(r.bytes); o.* = .{}; } }; @@ -377,7 +385,7 @@ pub fn handle(p: *Pardes, req: Req) Reply { }; return switch (req.op) { .lookup => lookup(p, req, target), - .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) }, + .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse runAttr(p, req, target) orelse return Reply.fail(req.tag, E.NOENT) }, .setattr => setattr(p, req, target), .open => open(p, req, target), .release => release(p, req), @@ -387,6 +395,24 @@ pub fn handle(p: *Pardes, req: Req) Reply { }; } +/// A run's answer outlives its pane, so its file still stats while a run +/// is open on it (a reader's fstat, cat's), as acme's fid keeps its window. +/// The stat names the open when it came on one; a mount's comes on the +/// file's walked name, and serials are never reused, so any run open on +/// this node is the reader's. +fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr { + const t = switch (target) { + .pane => |t| t, + .top => return null, + }; + if (t.file != .pty_run) return null; + const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| { + if (o.node == req.node and o.what == .run) break o; + } else null; + if ((run orelse return null).what != .run) return null; + return .{ .name = t.file.fileName(), .node = Node.of(t.serial, t.file), .mode = t.file.mode(), .mtime = p.fs.started }; +} + fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { switch (target) { .top => |f| return .{ @@ -569,7 +595,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { // closing it leaves the pane alone. .new => { const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); - p.newScratchBelow(p.active); + p.newScratchFor(p.active, .fs); const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); return .{ .tag = req.tag, .handle = made.serial }; }, @@ -692,6 +718,32 @@ fn setattr(p: *Pardes, req: Req, target: Target) Reply { } fn read(p: *Pardes, req: Req, target: Target) Reply { + const stream = if (openOf(p, req)) |o| switch (o.what) { + .log => o, + .event => |reader| if (reader) o else null, + .pty_data => o, + else => null, + } else null; + const s = stream orelse return readFile(p, req, target); + // Strictly inside: a client that reads every record at one offset (the + // offset is ignored on a stream, as in Plan 9) always gets new ones. + if (s.replay) |r| if (req.off > r.off and req.off < r.off + r.bytes.len) { + const from = r.bytes[@intCast(req.off - r.off)..]; + const n = @min(from.len, req.size); + p.fs.stage(p.gpa).appendSlice(p.gpa, from[0..n]) catch return Reply.fail(req.tag, E.NOMEM); + return .{ .tag = req.tag, .payload = .{ .staged = @intCast(n) } }; + }; + const reply = readFile(p, req, target); + if (reply.status != .ok) return reply; + const bytes = p.fsPayload(reply); + if (bytes.len == 0) return reply; + const kept = p.gpa.dupe(u8, bytes) catch return reply; + if (s.replay) |r| p.gpa.free(r.bytes); + s.replay = .{ .off = req.off, .bytes = kept }; + return reply; +} + +fn readFile(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { .README => help: { |
