summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/addr.zig47
-rw-r--r--src/ninep/ctl.zig170
-rw-r--r--src/ninep/events.zig110
-rw-r--r--src/ninep/pane.zig34
-rw-r--r--src/ninep/pty.zig36
5 files changed, 372 insertions, 25 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index cc7ca632..0537c17a 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -14,8 +14,11 @@ fn clip(n: usize) u32 {
pub const e_no_match = "no match for regexp";
pub const e_range = "address out of range";
pub const e_regexp = "bad regular expression";
-pub const e_slow = "regular expression search took too long";
+/// Not "took too long": 9ns reads errors by their words, and that would be
+/// ENAMETOOLONG.
+pub const e_slow = "regular expression search gave up, backtracking past its step budget";
pub const e_syntax = "bad address syntax";
+pub const e_order = "addresses out of order";
pub const Addr = struct {
text: []const u8,
@@ -93,7 +96,9 @@ pub const Addr = struct {
size = .line;
},
'/', '?' => {
- const back = c == '?';
+ // `-/re/` searches back as `?re?` does (acme's address()
+ // takes the direction a `-` set, addr.c).
+ const back = c == '?' or dir == '-';
r = a.regexp(r, a.pattern(c), back) orelse return null;
dir = 0;
size = .line;
@@ -243,6 +248,44 @@ const Node = tree.Node;
const E = tree.E;
const Status = tree.Status;
+test "an address is evaluated from the current one, as acme's are" {
+ const p = try th.withFile(testing.allocator, "a foo b foo c foo\n");
+ defer p.deinit();
+ const addr = Node.of(th.serialOf(p), .addr);
+ const Step = struct { expr: []const u8, q0: u32, q1: u32 };
+ _ = th.wr(p, addr, "#0");
+ for ([_]Step{
+ // each search starts where the last address ended
+ .{ .expr = "/foo/", .q0 = 2, .q1 = 5 },
+ .{ .expr = "/foo/", .q0 = 8, .q1 = 11 },
+ // `.` is that address, and `-` before a pattern searches back
+ .{ .expr = ".", .q0 = 8, .q1 = 11 },
+ .{ .expr = "-/foo/", .q0 = 2, .q1 = 5 },
+ .{ .expr = "./foo/", .q0 = 8, .q1 = 11 },
+ .{ .expr = "+/foo/", .q0 = 14, .q1 = 17 },
+ // a number then a pattern: the search starts at the number
+ .{ .expr = "#9/foo/", .q0 = 14, .q1 = 17 },
+ }) |c| {
+ try testing.expectEqual(Status.ok, th.wr(p, addr, c.expr).reply.status);
+ try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1);
+ }
+ // A range whose end comes before its start is sam's error.
+ try testing.expectEqualStrings(e_order, th.wr(p, addr, "#10,#5").reply.ename);
+
+ // `;` evaluates its right side from its left (acme's addr.c:170-171,
+ // `ar = r`), `,` from the address the whole started at.
+ const q = try th.withFile(testing.allocator, "b a b\n");
+ defer q.deinit();
+ const qaddr = Node.of(th.serialOf(q), .addr);
+ _ = th.wr(q, qaddr, "#0");
+ try testing.expectEqual(Status.ok, th.wr(q, qaddr, "/a/;/b/").reply.status);
+ try testing.expectEqual(@as(u32, 2), q.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(@as(u32, 5), q.panes[0].?.fs.addr.q1);
+ _ = th.wr(q, qaddr, "#0");
+ try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
+}
+
test "regular expressions search lines as sam's do, and a search wraps" {
const p = try th.withFile(testing.allocator, "alpha beta\nbeta gamma\ngamma\n");
defer p.deinit();
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index cd327d03..a64521e7 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -53,8 +53,26 @@ fn noteResult(p: *Pardes, serial: u32) void {
/// the pane it acted on (even one it closed).
fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const context = p.panes[id].?.serial;
+ // A pane an event reader holds hears its clicks instead of acting on
+ // them (editors/acme/look.c:53 and exec.c:173 send them to winevent),
+ // and a line written to a look or exec is a click: on that pane, or for
+ // the root's, on the pane with the keyboard. The reader writes the
+ // record back to have it done.
+ if (p.panes[id].?.fs.readers > 0) {
+ const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null;
+ var flag: u32 = pardes.ctlfs.events.flag_expansion;
+ if (named) flag |= pardes.ctlfs.events.flag_builtin;
+ if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename;
+ _ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text);
+ return noteResult(p, context);
+ }
const before = Serials.snapshot(p);
- if (exec) _ = pardes.exec.execute(p, id, text) else look.lookAt(p, id, text);
+ // An exec answers the pane it went to: a command pane it reused, or
+ // the terminal it typed into.
+ const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
+ look.lookAt(p, id, text);
+ break :blk null;
+ };
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
@@ -62,6 +80,7 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
created = true;
};
if (created) return;
+ if (went) |dst| if (p.panes[dst]) |pane| return noteResult(p, pane.serial);
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
@@ -144,7 +163,7 @@ fn writeQuoted(w: *std.Io.Writer, s: []const u8) !void {
try w.writeByte('\'');
}
-pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len;
+pub const pane_text_cap = 7 * 12 + 2 * 256 + 4 + "notcurrent ".len + 48;
/// acme's winctlprint: serial, tag length, body length, a reserved zero, the
/// dirty flag, then the width in cells, the font and the tab width. Then
@@ -167,6 +186,10 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 {
// While a column's or the workspace's tag has the keyboard, no pane does.
const current = p.panes[p.active] == pane and !p.header_focus;
w.print(" {d:>11} {s} ", .{ config.tab_width, if (current) "current" else "notcurrent" }) catch {};
+ // A terminal bound as a REPL ends with its id, `python-a`.
+ var idbuf: [48]u8 = undefined;
+ const id = exec_line.replId(&idbuf, pane);
+ if (id.len > 0) w.print("{s} ", .{id}) catch {};
return w.buffered();
}
@@ -177,7 +200,7 @@ const Builtin = builtins.registry.Builtin();
/// Refuses a control message, quoting it the way Plan 9's cmderror does
/// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`.
fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply {
- const room = p.fs.ename.len - why.len - 3;
+ const room = p.fs.ename.len -| (why.len + 3);
const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why;
return tree.failText(req.tag, E.INVAL, text);
}
@@ -201,7 +224,12 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const takes = switch (setting.action) {
.theme => for (pardes.themes) |t| {
if (std.mem.eql(u8, t.name, arg)) break true;
- } else false,
+ } else {
+ // `refuse` quotes the line after it, in what is left of 128.
+ var near: [40]u8 = undefined;
+ var why: [104]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; ThemeSel lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
+ },
.font => config.Runtime.FontSpec.parse(arg) != null,
else => probe: {
var probe = p.settings;
@@ -220,7 +248,18 @@ fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply {
p.fs.refused = false;
p.fs.failure_len = 0;
defer p.fs.no_prompt = false;
+ // What a ctl write touched reads back from /look and /exec as a click's
+ // does (`run`), so a script that made a pane with `Tty` finds it there.
+ const context = p.panes[id].?.serial;
+ const before = Serials.snapshot(p);
_ = exec_line.executeBuiltinLine(p, id, line);
+ const after = Serials.snapshot(p);
+ var created = false;
+ for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
+ noteResult(p, serial);
+ created = true;
+ };
+ if (!created) noteResult(p, context);
if (p.fs.refused) return refuse(p, req, e_prompt, line);
if (p.fs.failure_len == 0) return null;
const refusal = refuse(p, req, p.fs.failure[0..p.fs.failure_len], line);
@@ -292,6 +331,7 @@ pub fn readRoot(p: *Pardes, req: Req) Reply {
/// runs, as the root's look and exec click there; all are checked first.
pub fn writeRoot(p: *Pardes, req: Req) Reply {
for ([2]bool{ false, true }) |apply| {
+ if (apply) p.fs.results_len = 0;
var it = std.mem.splitScalar(u8, req.data, '\n');
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
@@ -361,7 +401,7 @@ pub fn writeFocus(p: *Pardes, req: Req) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-const e_no_pane = "no such window";
+const e_no_pane = "no such pane";
pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply {
var buf: [pane_text_cap]u8 = undefined;
@@ -399,6 +439,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
// Checked whole before anything applies, so a write that must wait for
// the lock has done nothing yet when it goes again.
for ([2]bool{ false, true }) |apply| {
+ if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
var it = std.mem.splitScalar(u8, req.data, '\n');
while (it.next()) |raw| {
@@ -615,7 +656,9 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("not a session control message \"Del\"", wr(p, root_ctl, "Del").reply.ename);
try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
try testing.expectEqualStrings("bad value in control message \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
- try testing.expectEqualStrings("bad value in control message \"Theme no-such-theme\"", wr(p, root_ctl, "Theme no-such-theme").reply.ename);
+ const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
+ try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
+ try testing.expect(std.mem.endsWith(u8, no_theme, "; ThemeSel lists all \"Theme no-such-theme\""));
try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename);
try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename);
// A bare required word fails the check, so the line before never runs.
@@ -625,7 +668,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
// its line; the lines before it have taken effect, as in acme.
const failed = wr(p, root_ctl, "Verbose on\nMount x\nVerbose off");
try testing.expectEqual(E.IO, failed.errno());
- try testing.expectEqualStrings("Mount name dial: MissingArgument \"Mount x\"", failed.reply.ename);
+ try testing.expectEqualStrings("Mount name dial: missing argument \"Mount x\"", failed.reply.ename);
try testing.expect(p.settings.verbose);
try testing.expect(!p.fs.no_prompt);
try testing.expectEqual(Status.ok, wr(p, root_ctl, "Verbose off").reply.status);
@@ -860,3 +903,116 @@ test "exec Save writes the named file through the host" {
try testing.expect(!pane_files.dirtyOf(pane));
try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?);
}
+
+test "a pane's own look and exec go to the event reader holding it, which writes them back to act" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "Msg routed\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const event = Node.of(serial, .event);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle;
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg routed\n").reply.status);
+ // Not done: the reader has it, as it would a middle click.
+ const pane = p.panes[0].?;
+ try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null);
+ const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes;
+ try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record);
+ // Written back, it is done.
+ try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status);
+ try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null);
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status);
+ try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ // The root's is a click at the pane with the keyboard, this one.
+ try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null);
+ try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
+ // With no reader, the root's and the pane's act at once.
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "direct") != null);
+}
+
+test "look and exec read back what the last command touched, a ctl write's too" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var want: [32]u8 = undefined;
+ _ = wr(p, root_exec, "Msg first\n");
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, root_exec, 0, 64).bytes);
+ // A pane made through ctl is what both read next, not the Msg's pane.
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status);
+ const made = p.panes[p.active].?.serial;
+ try testing.expect(made != serial);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+ // A ctl write that makes nothing names the pane it ran at.
+ try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
+}
+
+test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" {
+ // 9ns (cloud9's 9ns/src/nine.zig) maps an error to an errno by its
+ // words, and EIO when none of them match: these words must not.
+ const addressing = @import("addr.zig");
+ const einval = [_][]const u8{ "control message", "invalid", "bad " };
+ const other = [_][]const u8{ "interrupt", "shut down", "not exist", "not found", "no such", "exists", "not empty", "not a dir", "is a dir", "permission", "denied", "read-only", "read only", "no space", "not allowed", "not permitted", "cannot", "fid", "busy", "in use", "too long", "not supported", "unsupported" };
+ const Case = struct { text: []const u8, malformed: bool };
+ for ([_]Case{
+ .{ .text = addressing.e_syntax, .malformed = true },
+ .{ .text = addressing.e_regexp, .malformed = true },
+ .{ .text = e_prompt, .malformed = true },
+ .{ .text = addressing.e_no_match, .malformed = false },
+ .{ .text = addressing.e_range, .malformed = false },
+ .{ .text = addressing.e_order, .malformed = false },
+ .{ .text = addressing.e_slow, .malformed = false },
+ .{ .text = "/tmp/x.txt: Modified (Exit again to discard)", .malformed = false },
+ }) |c| {
+ const says_einval = for (einval) |w| {
+ if (std.ascii.findIgnoreCase(c.text, w) != null) break true;
+ } else false;
+ try testing.expectEqual(c.malformed, says_einval);
+ for (other) |w| try testing.expect(std.ascii.findIgnoreCase(c.text, w) == null);
+ }
+}
+
+test "Joincol with no column to the right and Theme with no such theme say so" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const joined = wr(p, root_ctl, "Joincol\n");
+ try testing.expectEqual(tree.Status.err, joined.reply.status);
+ try testing.expectStringStartsWith(joined.reply.ename, "Joincol: no column to the right");
+ const themed = wr(p, root_ctl, "Theme drak\n");
+ try testing.expectEqual(E.INVAL, themed.errno());
+ try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: ");
+ try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null);
+ try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\""));
+ // A click on the word says it on the message row.
+ _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n");
+ const pane = p.panes[p.active].?;
+ try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null);
+}
+
+test "a look that fills a column's stillborn scratch is answered with the scratch already gone" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "file\n" });
+ 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.
+ 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));
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 69dc271d..ffdea95b 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -7,6 +7,7 @@ const look = @import("../look.zig");
const cloud9 = @import("cloud9");
const tree = @import("tree.zig");
const pane_files = @import("pane.zig");
+const dump = @import("../dump.zig");
const Pardes = pardes.Pardes;
const Pane = pardes.Pane;
@@ -155,6 +156,13 @@ pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
/// kernel mapped the reply to (`Invalid argument`), and here is the reason.
/// The log is the one place for it, as acme's `errors` file takes text and
/// answers nothing: a per-pane readable error file would be a second.
+/// `dump <path>` when a Dump is written, `restore <path>` first in a
+/// Restore's replacement.
+pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
+ var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined;
+ pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return);
+}
+
pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno);
var name: [16]u8 = undefined;
@@ -460,7 +468,7 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
p.fs.news = true;
}
-const EventRecord = struct { action: Action, q0: u32, q1: u32 };
+const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, text: ?[]const u8 = null };
const EventReader = struct {
data: []const u8,
@@ -470,15 +478,27 @@ const EventReader = struct {
if (er.i >= er.data.len) return null;
var i = er.i;
if (i + 2 > er.data.len) return null;
+ const origin = er.data[i];
i += 1;
const action = Action.fromChar(er.data[i]) orelse return null;
i += 1;
const q0 = scanNumber(er.data, &i) orelse return null;
const q1 = scanNumber(er.data, &i) orelse return null;
while (i < er.data.len and er.data[i] == ' ') i += 1;
+ // acme takes only `origin action q0 q1`; the record whole, as read,
+ // is taken too, so a click with no place in the text (one written
+ // to a pane's own look or exec) can be written back and done.
+ var text: ?[]const u8 = null;
+ if (i < er.data.len and er.data[i] != '\n') {
+ _ = scanNumber(er.data, &i) orelse return null;
+ const n = scanNumber(er.data, &i) orelse return null;
+ if (i >= er.data.len or er.data[i] != ' ' or er.data.len - (i + 1) < n) return null;
+ text = er.data[i + 1 ..][0..n];
+ i += 1 + n;
+ }
if (i >= er.data.len or er.data[i] != '\n') return null;
er.i = i + 1;
- return .{ .action = action, .q0 = q0, .q1 = q1 };
+ return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .text = text };
}
};
@@ -517,9 +537,15 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
const whole = if (r.action.onTag()) pane_files.tagOf(p, pane) else pane_files.bodyOf(pane);
const lo = @min(@as(usize, r.q0), whole.len);
const hi = @max(lo, @min(@as(usize, r.q1), whole.len));
- const text = p.scratch.allocator().dupe(u8, whole[lo..hi]) catch continue;
+ const said = if (r.q0 == r.q1) r.text orelse whole[lo..hi] else whole[lo..hi];
+ const text = p.scratch.allocator().dupe(u8, said) catch continue;
switch (r.action) {
- .body_exec, .tag_exec => _ = exec.execute(p, live, text),
+ // A body's click written back is done as the click would have
+ // been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for
+ // it takes it. One that came from a write to exec (`F`) was
+ // never a click, and runs as the command it was.
+ .body_exec => _ = exec.executeFrom(p, live, text, r.origin != 'F'),
+ .tag_exec => _ = exec.execute(p, live, text),
.body_look, .tag_look => look.lookAt(p, live, text),
else => unreachable,
}
@@ -664,7 +690,8 @@ test "a pane deleted while its event file is open leaves no suppression behind"
const b = call(p, .{ .tag = 19, .op = .open, .node = event, .omode = 1 });
try testing.expectEqual(@as(u16, 2), p.fs.listeners);
- _ = wr(p, Node.of(serial, .exec), "Del\n");
+ // Through ctl: a line written to its exec would go to the reader.
+ _ = wr(p, Node.of(serial, .ctl), "Del\n");
try testing.expect(p.paneBySerial(serial) == null);
try testing.expectEqual(@as(u16, 0), p.fs.listeners);
// The reader's next read hears what acme says of a window gone under it.
@@ -850,7 +877,7 @@ test "a refused or failed write is an err record in the log, saying which file a
_ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus");
try testing.expectEqualStrings("err - ctl: unknown control message \"Bogus\"\n", next.record(p, f, frozen));
_ = wr(p, @intFromEnum(tree.TopFile.focus), "99999");
- try testing.expectEqualStrings("err - focus: no such window\n", next.record(p, f, frozen));
+ try testing.expectEqualStrings("err - focus: no such pane\n", next.record(p, f, frozen));
// A write that succeeds says nothing.
_ = wr(p, Node.of(serial, .addr), "#0");
try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status);
@@ -870,6 +897,21 @@ test "a refused or failed write is an err record in the log, saying which file a
_ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g });
}
+test "a Dump written and a Restore made are in the log, with their files" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ p.setLastDump("/tmp/pardes.dump.zon");
+ try dump.dumpState(p);
+ const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon");
+ defer restored.deinit();
+ for ([_]*Pardes{ p, restored }, [_][]const u8{ "dump", "restore" }) |core, what| {
+ 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})));
+ }
+}
+
test "opens of the log share the open records, and a closed one frees its record" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
@@ -885,3 +927,59 @@ test "opens of the log share the open records, and a closed one frees its record
for (handles) |h| _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h });
for (p.fs.opens) |o| try testing.expect(o.node == 0);
}
+
+test "a click in the body reports the offsets of the word it takes, so the record can be written back" {
+ const config = @import("../config.zig");
+ const gpa = testing.allocator;
+ const text = "alpha qty beta\n qty\n\tqty\ndef f(qty): return qty.x\n" ++ "w" ** 100 ++ " qty\n";
+ const p = try withFile(gpa, text);
+ defer p.deinit();
+ // A scratch made over 9P, clicked while another pane has the keyboard.
+ const scratch = try th.newPane(p);
+ _ = wr(p, Node.of(scratch, .body), text);
+ for ([_]u32{ serialOf(p), scratch }) |serial| {
+ const id = p.paneBySerial(serial).?;
+ p.active = p.paneBySerial(if (serial == scratch) serialOf(p) else scratch).?;
+ const event = Node.of(serial, .event);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[id];
+ const x0 = rect.x + config.GUTTER + config.PREFIX_W;
+ const y0 = if (p.settings.tag_bottom) rect.y else rect.y + p.panes[id].?.tag_rows;
+ const tab: u16 = config.tab_width;
+ const Click = struct { x: u16, y: u16, look: []const u8, exec: []const u8, scroll: i32 = 0 };
+ // Where the wrapped line's `qty` landed, as drawn.
+ const surface = try p.render(frame.allocator());
+ var wrap_col: u16 = 0;
+ while (wrap_col < rect.w and !std.mem.eql(u8, surface.at(x0 + wrap_col, y0 + 5).grapheme(), "q")) wrap_col += 1;
+ for ([_]Click{
+ .{ .x = x0 + 7, .y = y0, .look = "ML6 9 0 3 qty\n", .exec = "MX6 9 0 3 qty\n" },
+ .{ .x = x0 + 5, .y = y0 + 1, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n" },
+ .{ .x = x0 + tab + 1, .y = y0 + 2, .look = "ML24 27 0 3 qty\n", .exec = "MX24 27 0 3 qty\n" },
+ .{ .x = x0 + 7, .y = y0 + 3, .look = "ML34 37 0 3 qty\n", .exec = "MX34 37 0 3 qty\n" },
+ // Scrolled a line, the first row shows the second.
+ .{ .x = x0 + 5, .y = y0, .look = "ML19 22 0 3 qty\n", .exec = "MX19 22 0 3 qty\n", .scroll = 1 },
+ // Wrapped: the word on the continuation row.
+ .{ .x = x0 + wrap_col, .y = y0 + 5, .look = "ML154 157 0 3 qty\n", .exec = "MX154 157 0 3 qty\n" },
+ }) |c| for ([_]pardes.Mouse.Button{ config.look_button, config.exec_button }, [_][]const u8{ c.look, c.exec }) |button, want| {
+ // The pointer rests on the word first, as a hand's does, and
+ // the hover preview comes up under it.
+ p.panes[id].?.scrollBy(c.scroll);
+ _ = try p.render(frame.allocator());
+ p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = c.x, .row = c.y } });
+ for (0..200) |_| p.update(.tick);
+ p.update(.{ .mouse = .{ .button = button, .kind = .press, .col = c.x, .row = c.y } });
+ p.update(.{ .mouse = .{ .button = button, .kind = .release, .col = c.x, .row = c.y } });
+ try testing.expectEqualStrings(want, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ p.panes[id].?.scrollBy(-c.scroll);
+ };
+ // A click on nothing expands to nothing, and acme then says
+ // nothing (look.c:37, exec.c:141).
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .press, .col = x0 + 60, .row = y0 + 1 } });
+ p.update(.{ .mouse = .{ .button = config.look_button, .kind = .release, .col = x0 + 60, .row = y0 + 1 } });
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h });
+ }
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 0a511118..d2b2a791 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -194,6 +194,20 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
return take;
}
+test "a terminal's body stats as long as it reads" {
+ const gpa = testing.allocator;
+ const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.isTerminal());
+ p.update(.{ .output = .{ .pane = 0, .bytes = "$ ls\r\nalpha beta\r\n$ " } });
+ const body = Node.of(pane.serial, .body);
+ const whole = rd(p, body, 0, 1 << 16);
+ try testing.expect(whole.bytes.len > 0);
+ try testing.expectEqual(@as(u64, whole.bytes.len), call(p, .{ .tag = 1, .op = .getattr, .node = body }).reply.attr.size);
+}
+
// ---- what stat reports ----
/// A range file's text: acme's addr format, two right-aligned offsets, so
@@ -204,7 +218,9 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
const pane = p.panes[id] orelse return 0;
const pf = &pane.fs;
return switch (f) {
- .body, .data, .xdata => bodyOf(pane).len,
+ // A terminal's body reads its screen and history, as text.
+ .body => if (pane.file == null and pane.isTerminal()) panes.Terminal.screenTextLen(pane) else bodyOf(pane).len,
+ .data, .xdata => bodyOf(pane).len,
.tag => tagOf(p, pane).len,
.name => nameOf(pane).len + 1,
.ctl => ctl: {
@@ -453,16 +469,22 @@ pub const e_addr_failed = "no address: the last one written to addr failed";
/// A range file takes an address expression, or that pair of offsets. One
/// that does not evaluate says why: `bad address syntax`, `no match for
-/// regexp`, `address out of range`, `bad regular expression`.
+/// regexp`, `address out of range`, `bad regular expression`, `addresses
+/// out of order`.
fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
const pf = &pane.fs;
const text = bodyOf(pane);
clampAddr(pf, text.len);
const expr = std.mem.trimEnd(u8, req.data, "\n");
var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr };
- const r = if (pairOf(expr)) |pair|
+ var r = if (pairOf(expr)) |pair|
State.Range{ .q0 = @min(pair.q0, clip(text.len)), .q1 = @min(pair.q1, clip(text.len)) }
else if (a.address(pf.addr)) |found| (if (a.i < expr.len) null else found) else null;
+ // sam's check, which acme leaves out: `#100,#50` names no range.
+ if (r) |found| if (found.q0 > found.q1) {
+ a.err = addressing.e_order;
+ r = null;
+ };
const range = r orelse {
if (file == .addr) pf.addr_failed = true;
return tree.failText(req.tag, E.INVAL, a.err);
@@ -485,7 +507,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 Reply.fail(req.tag, E.INVAL);
+ 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");
const on = word[0] == '1';
const pf = &pane.fs;
switch (file) {
@@ -851,8 +873,10 @@ test "the flag files read and write the state acme kept behind ctl verbs" {
for ([_]u64{ dirty, mark, scroll }) |node| {
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|
+ 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("0\n", rd(p, dirty, 0, 8).bytes);
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index dcf9a8a7..3599b612 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -43,7 +43,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -65,13 +65,13 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
const which = signalNamed(words.next() orelse return false) orelse return false;
if (words.next() != null) return false;
if (!apply) return true;
- p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .sig = which } });
+ p.emit(.{ .signal_pty = .{ .pane = @intCast(id), .serial = pane.serial, .sig = which } });
},
.exec => {
if (words.next() != null) return false;
if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
if (!apply) return true;
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(pane.cwdSlice()) } });
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
},
}
return true;
@@ -92,7 +92,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
// Busy is what a run written now would be told: a command is running
// or a line is typed at the prompt. Before the shell's first prompt a
// run waits for it rather than answer busy, so that is not busy.
- const marks = if (pane.terminal) |state| &state.stream.handler else null;
+ // A build with no terminal panes (the board) has no marks to read.
+ const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null;
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);
@@ -162,10 +163,14 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line);
for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line);
+ if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line);
const pf = &pane.fs;
const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line);
const marks = &state.stream.handler;
- if (pf.unmarked) {
+ if (pane.command != null) {
+ // Its child is the command, and no shell is there to take a line.
+ answer(p, slot, "error {s}", .{if (pane.command_done) "command done; not a shell" else "a command runs here, not a shell"});
+ } else if (pf.unmarked) {
answer(p, slot, "error no prompt marks", .{});
} else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or
!pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id))))
@@ -335,6 +340,7 @@ fn waitingRun(p: *Pardes, pane: *Pane) ?*Run {
}
const e_bad_line = "bad command line";
+const e_bad_pty_ctl = tree.e_bad_ctl ++ "; takes winsize COLS ROWS, sig INT|TERM|HUP|QUIT|KILL, or exec";
pub fn readData(p: *Pardes, req: Req, pf: *pane_files.State) Reply {
if (pf.pty_out.empty()) return .{ .tag = req.tag, .status = .again };
@@ -505,6 +511,7 @@ test "every pty/ctl verb, and every refusal" {
}) |bad| {
const a = wr(p, ctl, bad);
try testing.expectEqual(E.INVAL, a.errno());
+ try testing.expectEqualStrings(e_bad_pty_ctl, a.reply.ename); // names what it takes
try testing.expect(a.winsize == null);
try testing.expect(a.signal == null);
try testing.expect(!a.spawned);
@@ -743,6 +750,25 @@ test "run answers how its command ended once the shell is back at a prompt" {
for (p.fs.opens) |o| try testing.expect(o.node == 0);
}
+test "run on a command pane says no shell is there" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const node = Node.of(serialOf(p), .pty_run);
+ const pane = p.panes[0].?;
+ pane.command = try gpa.dupe(u8, "make");
+ for ([_]struct { bool, []const u8 }{
+ .{ false, "error a command runs here, not a shell\n" },
+ .{ true, "error command done; not a shell\n" },
+ }) |case| {
+ pane.command_done = case[0];
+ const o = call(p, .{ .tag = 1, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = o.reply.handle, .data = "ls\n" });
+ try testing.expectEqualStrings(case[1], call(p, .{ .tag = 3, .op = .read, .node = node, .handle = o.reply.handle, .size = 64 }).bytes);
+ _ = call(p, .{ .tag = 4, .op = .release, .node = node, .handle = o.reply.handle });
+ }
+}
+
test "a run's answer says cut when its output's start is gone, and reads a bounded tail" {
const gpa = testing.allocator;
const p = try withTerm(gpa);