summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/addr.zig56
-rw-r--r--src/ninep/ctl.zig252
-rw-r--r--src/ninep/events.zig33
-rw-r--r--src/ninep/pane.zig73
-rw-r--r--src/ninep/pty.zig101
-rw-r--r--src/ninep/testing.zig9
-rw-r--r--src/ninep/tree.zig56
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: {