diff options
Diffstat (limited to 'src/ninep/ctl.zig')
| -rw-r--r-- | src/ninep/ctl.zig | 269 |
1 files changed, 257 insertions, 12 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 477a91fe..c2a78fb8 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -388,11 +388,11 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? const b = std.meta.stringToEnum(Builtin, word) orelse { // A setting this build's frontend cannot show (Lift, GripWidth on a // terminal) is known, and says so rather than sound misspelt. - if (config.Runtime.find(word) != null) { + if (builtins.absentWhy(word) orelse if (config.Runtime.find(word) != null) @as([]const u8, "GUI-only") else null) |absent| { var why: [64]u8 = undefined; // EINVAL, a request this build cannot take, in words 9ns reads // as that: `invalid`. - return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only, invalid here", .{word}) catch "GUI-only, invalid here", line); + return refuse(p, req, std.fmt.bufPrint(&why, "{s} is {s}, invalid here", .{ word, absent }) catch "GUI-only, invalid here", line); } return refuse(p, req, "unknown control message", line); }; @@ -408,13 +408,15 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? 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; const takes = switch (setting.action) { - .theme => for (pardes.themes) |t| { - if (std.mem.eql(u8, t.name, arg)) break true; - } 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}; Themes lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line); + .theme => if (pardes.colors.themeIndex(arg) != null) true else { + // `refuse` quotes the line after it, all in the 128 bytes an + // Rerror carries: the names get what is left. + const head = "bad value in control message; like it: "; + const tail = "; Themes lists all"; + var near: [128]u8 = undefined; + const room = @import("cloud9").fs.errmax -| (head.len + tail.len + line.len + 3); + var why: [256]u8 = undefined; + return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", line); }, .font => config.Runtime.FontSpec.parse(arg) != null, else => probe: { @@ -864,6 +866,8 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { const id = p.paneBySerial(pane.serial).?; // Kept in +Unsaved, as Exit's refusal is; the notice is short. The // write's err record logs it, not a msg too. + // And logged `unsaved <serial> <name>`, as the other refusals are. + tree.events.noteLog(p, .unsaved, pane); const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null; if (listed) |content| panes.Output.openUnsaved(p, id, content); p.fs.unlogged = true; @@ -1465,6 +1469,8 @@ test "get reloads the pane from disk and reports a missing file without losing d 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)")); + var unsaved: [4200]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&unsaved, "unsaved {d} {s}/missing.txt\n", .{ serial, directory }))); 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); @@ -1610,12 +1616,34 @@ test "Joincol with no column to the right and Theme with no such theme say so" { 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.indexOf(u8, themed.reply.ename, ": dr") != 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); + // Within the 128 bytes an Rerror carries, the quoted line kept. + try testing.expect(themed.reply.ename.len <= 128); + // A name in any case is the theme. + try testing.expectEqual(Status.ok, wr(p, root_ctl, "Theme DRACULA\n").reply.status); + try testing.expectEqualStrings("dracula", pardes.themes[p.settings.theme].name); + // A misspelling far from any first letter's list still finds its like. + var near: [170]u8 = undefined; + try testing.expect(std.mem.indexOf(u8, pardes.colors.themesNear(&near, "9dracula"), "dracula") != null); + // Every theme sharing the first letter, when they fit. + var tested = false; + for ("abcdefghijklmnopqrstuvwxyz") |letter| { + var count: usize = 0; + var len: usize = 0; + for (pardes.themes) |t| if (t.name[0] == letter) { + count += 1; + len += t.name.len + 1; + }; + if (count < 2 or len > near.len) continue; + try testing.expectEqual(count, std.mem.count(u8, pardes.colors.themesNear(&near, &.{letter}), " ") + 1); + tested = true; + } + try testing.expect(tested); } test "a look after Newcol fills the empty column the keyboard is on" { @@ -1663,6 +1691,10 @@ test "a Restore of a file that is no dump fails the write before any warning, an p.fs.late_failure_len = 0; p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound); try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: no such directory", p.fs.late_failure[0..p.fs.late_failure_len]); + // A DumpDir that is there but no directory says that. + p.fs.late_failure_len = 0; + p.dumpFailed("/dev/null/pardes.dump.zon", error.NotDir); + try testing.expectEqualStrings("Dump /dev/null/pardes.dump.zon: /dev/null is not a directory", p.fs.late_failure[0..p.fs.late_failure_len]); } test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" { @@ -1672,6 +1704,14 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back if (!builtins.capabilities.lift) { try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename); try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); + // A builtin the GUI alone has, written to a ctl or an exec, never + // runs as a shell command. + if (comptime !pardes.font_picker) { + try testing.expectEqualStrings("Fonts is GUI-only, invalid here \"Fonts\"", wr(p, root_ctl, "Fonts\n").reply.ename); + const ran = wr(p, root_exec, "Fonts\n"); + try testing.expectEqual(E.IO, ran.errno()); + try testing.expectEqualStrings("Fonts is GUI-only, invalid here", ran.reply.ename); + } } try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename); var buf: [900]u8 = undefined; @@ -1860,6 +1900,18 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir try testing.expect(std.mem.indexOf(u8, found.file.?.content, "other.txt") != null); try testing.expectEqual(grepped.serial, found.serial); try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") == null); + // The same Find again reads back the +Search, not the pane it ran in. + try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status); + try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes); + // A Grep run from the +Search itself fills that one, never a second. + try testing.expectEqual(Status.ok, wr(p, Node.of(found.serial, .exec), "Grep bravo\n").reply.status); + try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes); + var searches: usize = 0; + for (p.panes) |slot| if (slot) |q| if (q.file) |f| if (f.output != null) { + searches += @intFromBool(std.mem.endsWith(u8, f.path, "/+Search")); + }; + try testing.expectEqual(@as(usize, 1), searches); + try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") != null); // A second pane in a directory that is not there. const other = try th.newPane(p); _ = wr(p, Node.of(other, .name), "/tmp/pardes-no-such-dir-zz/b.txt\n"); @@ -1874,6 +1926,8 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); + // The +Search the miss would have filled is emptied, not left stale. + try testing.expectEqualStrings("", found.file.?.content); } test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" { @@ -1982,6 +2036,49 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]); } +test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const Starting = struct { + fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} + }; + p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; + defer p.host = .{}; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp\n", .flags = .{ .permissions = .executable_file } }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + const serial = serialOf(p); + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const refused = wr(p, Node.of(serial, .ctl), try std.fmt.bufPrint(&line, "Tty {s}/bad\n", .{dir})); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectEqualStrings("Tty: interpreter /no/such/interp not found", refused.reply.ename); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + // The log gained the err and nothing else: no new, msg or del. + try testing.expect(th.logHas(p, "Tty: interpreter /no/such/interp not found\n")); + try testing.expect(!th.logHas(p, "\nmsg ")); + try testing.expect(!th.logHas(p, "\ndel ")); + // The shell to start again is that script: refused before the running + // one goes. + const id = p.paneBySerial(serial).?; + p.panes[id].?.shell = try p.gpa.dupe(u8, try std.fmt.bufPrint(&line, "{s}/bad", .{dir})); + p.setCwd(id, dir); + const exec_refused = wr(p, Node.of(serial, .pty_ctl), "exec\n"); + try testing.expectEqual(E.NOENT, exec_refused.errno()); + try testing.expectEqualStrings("exec: interpreter /no/such/interp not found", exec_refused.reply.ename); + // One the host found could not start (the script changed after): said + // with the interpreter's name, and the running shell is not gone. + p.fs.late_failure_len = 0; + p.restartFailed(@intCast(id), error.InterpreterNotFound); + try testing.expectEqualStrings("shell: interpreter /no/such/interp not found", p.fs.late_failure[0..p.fs.late_failure_len]); + try testing.expect(!p.panes[id].?.shell_failed); +} + test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2022,6 +2119,26 @@ test "/index shows a name as the log does: a newline in it is \\n, controls spac try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null); } +test "a look at a ./ or ../ name that is not there names the file, ENOENT; a plain one is looked for as text" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDirPath(testing.io, "many"); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/many/a.txt\n", .{dir})); + for ([_][]const u8{ "./zz.txt", "../zz.txt" }) |name| { + const r = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}\n", .{name})); + try testing.expectEqual(E.NOENT, r.errno()); + var want: [64]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "look: {s}: no such file", .{name}), r.reply.ename); + } + try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .look), "zz.txt\n").reply.status); + try testing.expect(th.logHas(p, "look: no match for \"zz.txt\"")); +} + test "a served-tree pane spoils no Grep, and Look . from a gone directory is ENOENT, not /virtual/" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2141,8 +2258,8 @@ test "a closed file is found again: Recent and /recent list it closed, a look at const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; const at = std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}:3:", .{a})).?; const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?]; - try testing.expect(std.mem.endsWith(u8, row, " closed")); - try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " closed".len]})).reply.status); + try testing.expect(std.mem.endsWith(u8, row, " (closed)")); + try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " (closed)".len]})).reply.status); p.sync(); const back = p.panes[p.active].?; try testing.expectEqualStrings(a, back.file.?.path); @@ -2163,6 +2280,134 @@ test "a closed file is found again: Recent and /recent list it closed, a look at try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path); } +test "Back and Forward over a closed file's entries: one reopen is that file's pane in every entry, so the next step moves" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var long: [400]u8 = undefined; + var n: usize = 0; + for (0..100) |_| { + @memcpy(long[n..][0..4], "row\n"); + n += 4; + } + try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = long[0..n] }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\n" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "sea\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})); + p.sync(); + const b_serial = p.panes[p.active].?.serial; + // Two entries for a.txt, far apart in it. + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:1\n", .{dir})); + p.sync(); + const a = p.active; + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:90\n", .{dir})); + p.sync(); + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt\n", .{dir})); + p.sync(); + const c_serial = p.panes[p.active].?.serial; + _ = wr(p, Node.of(p.panes[a].?.serial, .ctl), "Del\n"); + p.sync(); + var closed: usize = 0; + for (p.jumps[0..p.njumps]) |j| closed += @intFromBool(j.serial == 0); + try testing.expectEqual(@as(usize, 2), closed); + // Back reopens it at line 90: both its entries are that pane now. + p.jumpBy(-1); + p.sync(); + const re = p.panes[p.active].?; + try testing.expect(std.mem.endsWith(u8, re.file.?.path, "/a.txt")); + try testing.expectEqual(@as(i32, 89), re.body.cur_row); + for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0); + // Back again goes to its line 1, in the same pane, then to b. + p.jumpBy(-1); + p.sync(); + try testing.expectEqual(re.serial, p.panes[p.active].?.serial); + try testing.expectEqual(@as(i32, 0), re.body.cur_row); + p.jumpBy(-1); + p.sync(); + try testing.expectEqual(b_serial, p.panes[p.active].?.serial); + // And Forward walks it all again, to c. + p.jumpBy(1); + p.sync(); + p.jumpBy(1); + p.sync(); + try testing.expectEqual(@as(i32, 89), re.body.cur_row); + p.jumpBy(1); + p.sync(); + try testing.expectEqual(c_serial, p.panes[p.active].?.serial); +} + +test "Recent keeps only files on disk: not a name never saved, nor the served tree's" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "real.txt", .data = "real\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + const names = [_][]const u8{ try std.fmt.allocPrint(testing.allocator, "{s}/c", .{dir}), try testing.allocator.dupe(u8, "/virtual/zz"), try std.fmt.allocPrint(testing.allocator, "{s}/real.txt", .{dir}) }; + defer for (names) |n| testing.allocator.free(n); + for (names) |name| { + const made = try th.newPane(p); + _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&line, "{s}\n", .{name})); + p.sync(); + _ = wr(p, Node.of(made, .ctl), "Del\n"); + _ = wr(p, Node.of(made, .ctl), "Del\n"); + p.sync(); + } + try testing.expect(p.recent.get(names[0]) == null); + try testing.expect(p.recent.get(names[1]) == null); + try testing.expect(p.recent.get(names[2]) != null); +} + +test "/recent shows a name as /index does, a newline in it escaped" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + p.recent.opened(p.recentGpa(), "/tmp/a\nb\\c"); + const text = rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes; + try testing.expect(std.mem.indexOf(u8, text, "closed /tmp/a\\nb\\\\c\n") != null); +} + +test "a remembered place is clamped to the file as it is now, its selection's two ends kept where they fit" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\nthree\nfour\nfive\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt:2\n", .{dir})); + p.sync(); + const c = p.panes[p.active].?; + // A selection over lines 2 to 4, then closed. + @import("pane.zig").setDot(c, .{ .q0 = 4, .q1 = 19 }); + try testing.expect(c.body.vsel.active); + _ = wr(p, Node.of(c.serial, .ctl), "Del\n"); + p.sync(); + // The file shrinks to two lines while it is closed. + try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\n" }); + _ = wr(p, root_exec, "Recent\n"); + const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; + const at = std.mem.indexOf(u8, rows.file.?.content, "c.txt:").?; + const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?]; + // Both ends are in the row, a range. + try testing.expect(std.mem.indexOfScalar(u8, row, '-') != null); + var full: [4400]u8 = undefined; + const loc = try std.fmt.bufPrint(&full, "{s}/{s}\n", .{ dir, row[0 .. std.mem.indexOfScalar(u8, row, ' ').?] }); + // A look at it opens the file, never a miss, clamped to its last line. + try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), loc).reply.status); + p.sync(); + const back = p.panes[p.active].?; + try testing.expect(std.mem.endsWith(u8, back.file.?.path, "/c.txt")); + try testing.expect(back.body.cur_row <= 1); + try testing.expect(back.body.vsel.active); +} + test "size is monotonic: growing is never refused, and a size once taken is taken again" { const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 }); defer p.deinit(); |
