diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/cols.zig | 89 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 506 | ||||
| -rw-r--r-- | src/ninep/events.zig | 115 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 82 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 11 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 42 |
6 files changed, 761 insertions, 84 deletions
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index b00ecdba..aae6d364 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -78,6 +78,7 @@ pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply { pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply { const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text; if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + if (pardes.ctlfs.pane.tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why); const was = headerText(p, serial).?; var data = req.data; const rewriting = p.fs.header_rewrite == rewriteKey(serial); @@ -126,6 +127,8 @@ pub fn released(p: *Pardes, serial: ?u32) void { /// Delcol, Joincol, New, Tty -- one a line, each run as from that tag. What /// they say is the session's in the log (`msg -`), not the focused pane's. pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { + // What this write touched, alone, reads back (runBuiltin notes it). + p.fs.results_len = 0; p.fs.session_write = true; defer p.fs.session_write = false; var lines = std.mem.splitScalar(u8, req.data, '\n'); @@ -164,8 +167,14 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0) continue; - for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control); + for (line) |c| if ((c < ' ' and c != '\t') or c == 0x7f) return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control); if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal; + // A pane's word (Undo, Msg, Save) is no word of a tag no pane owns: + // refused as the root's ctl refuses it, never done at whichever + // pane has the keyboard. The tag's own words (New, Tty, Find, Grep + // in a column's) are its to run. + if (paneWord(line, if (serial == null) pardes.config.topbar_str else tagline.column_tag_default)) + return pardes.ctlfs.ctl.refuseTo(p, req, "not a session control message", line, "pane/<n>/ctl"); const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null; if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); p.exec_column = col; @@ -189,6 +198,75 @@ test "New written to a column's exec makes its scratch in the session's director try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path); } +test "New written to a column's ctl reads back the new pane alone, as its exec does" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const root_exec = @intFromEnum(tree.TopFile.exec); + _ = th.wr(p, root_exec, "Msg before\n"); + const ctl_node = tree.Node.ofCol(layout.columnSerial(p, 0), .ctl); + try testing.expectEqual(tree.Status.ok, th.wr(p, ctl_node, "New\n").reply.status); + var want: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{p.panes[p.active].?.serial}), th.rd(p, root_exec, 0, 64).bytes); +} + +/// Whether `line` names a builtin that acts on a pane (every one of pane +/// scope, Edit too, which the root's ctl takes at the active pane) and is +/// not one of `own`, the words its tag holds by default: those a tag no +/// pane owns refuses, never doing them at the pane with the keyboard. +fn paneWord(line: []const u8, own: []const u8) bool { + const cmd = pardes.exec.commandText(line); + const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len]; + const b = std.meta.stringToEnum(pardes.builtins.registry.Builtin(), word) orelse return false; + if (pardes.builtins.registry.scope(b) == .session) return false; + var words = std.mem.tokenizeScalar(u8, own, ' '); + while (words.next()) |w| if (std.mem.eql(u8, w, word)) return false; + return true; +} + +test "a pane's word written to /tagexec or a column's exec is refused, and acts on no pane" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); + for ([_]u64{ @intFromEnum(tree.TopFile.tagexec), col_exec }) |node| { + for ([_][]const u8{ "Undo\n", "Msg hi\n", "Redo\n" }) |line| { + const refused = th.wr(p, node, line); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "not a session control message"); + } + } + // The column tag's own words still run there. + try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status); +} + +test "every pane word a tag no pane owns does not hold is refused there, Edit included, and the pane is untouched" { + const p = try th.withFile(testing.allocator, "keep me\n"); + defer p.deinit(); + const Builtin = pardes.builtins.registry.Builtin(); + const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); + for ([_]struct { node: u64, own: []const u8 }{ + .{ .node = @intFromEnum(tree.TopFile.tagexec), .own = pardes.config.topbar_str }, + .{ .node = col_exec, .own = tagline.column_tag_default }, + }) |tag_file| { + var checked: usize = 0; + for (std.enums.values(Builtin)) |b| { + if (pardes.builtins.registry.scope(b) == .session) continue; + var owned = false; + var words = std.mem.tokenizeScalar(u8, tag_file.own, ' '); + while (words.next()) |w| owned = owned or std.mem.eql(u8, w, @tagName(b)); + if (owned) continue; + var line: [64]u8 = undefined; + const text = try std.fmt.bufPrint(&line, "{s}{s}\n", .{ @tagName(b), if (b == .Edit) " ,d" else if (pardes.builtins.registry.requiresArg(b)) " x" else "" }); + const refused = th.wr(p, tag_file.node, text); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "not a session control message"); + checked += 1; + } + try testing.expect(checked > 20); + } + // `Edit ,d` wiped the pane with the keyboard: nothing touched it. + try testing.expectEqualStrings("keep me\n", p.panes[0].?.file.?.content); +} + /// rmdir of a column closes it when it is empty; one with panes is /// refused, saying so (close them, or Delcol on its ctl, which asks first). pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply { @@ -323,8 +401,10 @@ test "tagexec runs a word as a click in the workspace tag; exec files read back try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status); try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status); try testing.expectEqual(@as(usize, 2), p.ncol); - try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Msg from the top\n").reply.status); - try testing.expect(th.logHas(p, "msg - from the top\n")); + // What it says is the session's (Newcol announcing itself); a pane's + // word such as Msg is refused there. + try testing.expect(th.logHas(p, "msg - Newcol\n")); + try testing.expectEqual(E.INVAL, th.wr(p, tagexec, "Msg from the top\n").errno()); // A column's exec makes a pane there, reads it back, and its Msg is // the session's too. const right = layout.columnSerial(p, 1); @@ -333,8 +413,7 @@ test "tagexec runs a word as a click in the workspace tag; exec files read back var want: [16]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes); try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes); - try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "Msg from a column\n").reply.status); - try testing.expect(th.logHas(p, "msg - from a column\n")); + try testing.expect(th.logHas(p, "msg - New\n")); } test "a word run from the workspace's or a column's tag runs in the session's directory, a pane's in its own" { diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 4f52d661..477a91fe 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -73,6 +73,7 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo // An exec answers the pane it went to: a command pane it reused, or // the terminal it typed into. p.look_missed = false; + p.fs.refilled = null; const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: { look.lookAt(p, id, text); break :blk null; @@ -88,6 +89,9 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo created = true; }; if (created) return; + // A +Search it filled again (Find and Grep reuse one): that is what it + // touched. + if (p.fs.refilled) |serial| if (p.paneBySerial(serial) != null) return noteResult(p, serial); 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); } @@ -170,14 +174,16 @@ pub fn completeEnd(p: *Pardes, data: []const u8) usize { /// A null `serial` clicks at whichever pane is active as each line runs, /// which is what the root's own look and exec mean; a serial stays put. pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Reply { - p.fs.ran_at = 0; + // The root's lines run at the keyboard's pane: a refusal before any runs + // is that pane's too, in its err record. + p.fs.ran_at = if (serial == null) (if (p.panes[p.active]) |pane| pane.serial else 0) else 0; for ([2]bool{ false, true }) |apply| { if (apply) p.fs.results_len = 0; var it = Messages.init(p, req.data); while (it.next()) |text| { if (text.len == 0) continue; // Only an Edit block holds newlines (Messages). - for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return tree.failText(req.tag, E.INVAL, e_control); + for (text) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return tree.failText(req.tag, E.INVAL, e_control); if (exec) if (tooLong(req, text)) |refusal| return refusal; if (!apply) continue; const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; @@ -196,7 +202,9 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -pub const e_too_long = std.fmt.comptimePrint("a command line is at most {d} bytes", .{pardes.exec.command_max}); +// "invalid" is the word 9ns reads as EINVAL (enameToErrno): through a +// mount the refusal is EINVAL, as the reply's errno is. +pub const e_too_long = std.fmt.comptimePrint("invalid command line: a command line is at most {d} bytes", .{pardes.exec.command_max}); /// A line an exec would run as a command, over the most a command line may /// be: refused whole, before anything runs, rather than said on the message @@ -210,12 +218,22 @@ pub fn tooLong(req: Req, line: []const u8) ?Reply { return tree.failText(req.tag, E.INVAL, e_too_long); } -pub const e_control = "a command line holds no control character but a tab"; +pub const e_control = "invalid command line: it holds a control character (or DEL) other than a tab"; /// Runs one click (`run`) as a 9P write's: a builtin that fails there fails /// the write, with its words and an err record, and no msg, as a ctl line /// does (runBuiltin). The refusal, or null. pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, in_tag: bool) ?Reply { + // A builtin that means nothing without its argument, written bare: the + // write is refused, EINVAL, as a ctl's is (checkBuiltin), not run to + // fail or to ask. + if (exec) { + const cmd = exec_line.commandText(text); + if (std.meta.stringToEnum(Builtin, cmd)) |b| if (builtins.registry.requiresArg(b)) { + p.fs.results_len = 0; + return refuse(p, req, "wrong #args in control message", cmd); + }; + } p.fs.capturing = true; p.fs.failure_len = 0; defer p.fs.capturing = false; @@ -225,7 +243,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i p.fs.results_len = 0; const failure = p.fs.failure[0..p.fs.failure_len]; // A slot or a place refused keeps its errno words (no space: ENOSPC). - return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); + return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); +} + +/// The errno a builtin's failure answers with: ENOENT for what is not +/// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else +/// EIO. +fn failureErrno(failure: []const u8) u16 { + return if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) E.NOENT else E.IO; } pub fn resultsLen(p: *Pardes) u64 { @@ -344,7 +369,7 @@ fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { } /// `refuse`, saying which ctl takes the message instead. -fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { +pub 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); @@ -365,7 +390,9 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? // terminal) is known, and says so rather than sound misspelt. if (config.Runtime.find(word) != null) { var why: [64]u8 = undefined; - return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only", .{word}) catch "GUI-only", line); + // 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, "unknown control message", line); }; @@ -387,7 +414,7 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ? // `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); + 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); }, .font => config.Runtime.FontSpec.parse(arg) != null, else => probe: { @@ -426,11 +453,13 @@ pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { if (!created) noteResult(p, context); if (p.fs.refused) return refuse(p, req, e_prompt, line); if (p.fs.failure_len == 0) return null; - // An Edit's failure names what failed (the pattern that missed): its - // line, a program, is not quoted after it. - const edit = std.mem.startsWith(u8, line, "Edit ") or std.mem.eql(u8, line, "Edit"); - const refusal = if (edit) tree.failText(req.tag, E.IO, p.fs.failure[0..p.fs.failure_len]) else refuse(p, req, p.fs.failure[0..p.fs.failure_len], line); - return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = refusal.ename }; + // A line that ran and failed says why in the builtin's own words, which + // name what failed (`Mount peer /tmp/s: dial failed: ...`): the line is + // not quoted after them, as an exec's is not. Only a line refused before + // it runs, as no message at all, quotes it (`unknown control message + // "X"`). + const failure = p.fs.failure[0..p.fs.failure_len]; + return tree.failText(req.tag, failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); } const e_prompt = "control message needs its argument"; @@ -753,14 +782,20 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { var said_buf: [320]u8 = undefined; const said = said_buf[0..said_in_ename.len]; @memcpy(said, said_in_ename); - const r = refuse(p, req, said, line); - return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = r.ename }; + // It ran and was refused: its words, not the line quoted. + return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{said}) catch said); } - const errno = get(p, pane); - // Naming the path, in words 9ns reads as the errno. + var failed: anyerror = error.Unexpected; + const errno = get(p, pane, &failed); + // Naming the path, in words 9ns reads as the errno; a peer + // out of reach as a look or a Save says it. if (errno != 0) { const path = if (pane_files.fileOf(pane)) |f| f.path else ""; - return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (errno == E.NOENT) "no such file" else "cannot be read" }) catch "get: no such file"); + var why_buf: [48]u8 = undefined; + const why = if (pardes.Messages.dialReason(failed)) |r| + std.fmt.bufPrint(&why_buf, "dial failed: {s}", .{r}) catch "dial failed" + else if (errno == E.NOENT) "no such file" else "cannot be read"; + return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], why }) catch "get: no such file"); } } else if (std.mem.eql(u8, line, "lock")) { if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); @@ -826,20 +861,26 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { 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)"; - // Shown; the write's err record logs it, not a msg too. + 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. + 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; - defer p.fs.unlogged = false; - p.setMessage(p.paneBySerial(pane.serial).?, said); - return said; + p.setMessage(id, "1 unsaved pane \u{2014} get again to discard"); + p.fs.unlogged = false; + return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; } -fn get(p: *Pardes, pane: *Pane) u16 { +fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { const f = pane_files.fileOf(pane) orelse return 0; if (!panes.Output.fileTraits(f.output).saves) return 0; - const bytes = fs.read(p, f.path) catch |err| return switch (err) { - error.FileNotFound => E.NOENT, - else => E.IO, + const bytes = fs.read(p, f.path) catch |err| { + failed.* = err; + return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, + }; }; // Throwing unsaved edits away for it is a reload the log hears of, as a // clean one from the watch is. @@ -1055,7 +1096,7 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").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.expect(std.mem.endsWith(u8, no_theme, "; Themes 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. @@ -1065,7 +1106,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: missing argument \"Mount x\"", failed.reply.ename); + try testing.expectEqualStrings("Mount name dial: missing argument", 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); @@ -1413,16 +1454,17 @@ test "get reloads the pane from disk and reports a missing file without losing d try testing.expectEqualStrings("from disk\n", pane.file.?.content); try testing.expect(!pane_files.dirtyOf(pane)); try testing.expect(pane.file.?.history.undo_len > 0); - // What it had (renamed, so dirty) was thrown away for it: logged. + // Renamed but never edited, it was clean: nothing thrown away, nothing + // logged as reloaded over edits. var reloaded: [32]u8 = undefined; - try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial}))); + try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial}))); _ = 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) \"get\"")); + 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); @@ -1436,7 +1478,7 @@ test "exec Save writes the named file through the host" { const serial = serialOf(p); const pane = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status); - try testing.expect(pane_files.dirtyOf(pane)); + try testing.expect(!pane_files.dirtyOf(pane)); // a name alone is no edit; Save writes all the same const saved = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expect(saved.saved); @@ -1504,6 +1546,11 @@ test "a builtin's Verbose announcement is logged only when it succeeds, its fail const serial = serialOf(p); var want: [64]u8 = undefined; const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial}); + // A Save a ctl write gives no name to, on a scratch: refused, no Save. + const scratch = try th.newPane(p); + var said_save_buf: [64]u8 = undefined; + try testing.expectEqual(E.INVAL, wr(p, Node.of(scratch, .ctl), "Save\n").errno()); + try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&said_save_buf, "msg {d} Save\n", .{scratch}))); // Refused as it runs, through a click: the write fails, and no Exit. const f = &p.panes[0].?.file.?; f.saved_revision = f.revision -% 1; @@ -1615,7 +1662,7 @@ 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: file not found", p.fs.late_failure[0..p.fs.late_failure_len]); + try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: no such 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" { @@ -1623,8 +1670,8 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); if (!builtins.capabilities.lift) { - try testing.expectEqualStrings("Lift is GUI-only \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename); - try testing.expectEqualStrings("GripWidth is GUI-only \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); + 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); } try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename); var buf: [900]u8 = undefined; @@ -1665,7 +1712,7 @@ test "a builtin that fails a ctl write logs only its err, so the same failure ag const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; - try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word \"Kill zzz\" (x3)\n")); + try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word (x3)\n")); try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } @@ -1677,7 +1724,10 @@ test "an exec line over the command limit fails the write and says the limit; a const refused = wr(p, root_exec, long); try testing.expectEqual(E.INVAL, refused.errno()); try testing.expectEqualStrings(e_too_long, refused.reply.ename); - try testing.expect(th.logHas(p, "a command line is at most 1024 bytes")); + var rec: [96]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: a command line is at most 1024 bytes", .{serialOf(p)}))); + try testing.expectEqualStrings(e_control, wr(p, root_exec, "echo a\x01b\n").reply.ename); + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err {d} exec: invalid command line: it holds", .{serialOf(p)}))); try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status); } @@ -1721,8 +1771,13 @@ test "Mount dials its peer and fails, mounting nothing, when none answers; a loo const dead = "/tmp/pardes-test-no-such-peer.sock"; const refused = wr(p, root_ctl, "Mount dead " ++ dead ++ "\n"); try testing.expectEqual(E.IO, refused.errno()); - try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer \"Mount dead " ++ dead ++ "\"", refused.reply.ename); + try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer", refused.reply.ename); try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len); + // A pane of a peer gone since: get says so too. + try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "away"), .dial = try p.gpa.dupe(u8, dead) }); + _ = wr(p, Node.of(serialOf(p), .name), "/n/away/f.txt\n"); + const got = wr(p, Node.of(serialOf(p), .ctl), "get\n"); + try testing.expectEqualStrings("get: /n/away/f.txt: dial failed: no answer", got.reply.ename); // Mounted when it answered, gone since: a look through it says so. try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "gone"), .dial = try p.gpa.dupe(u8, dead) }); const looked = wr(p, @intFromEnum(tree.TopFile.look), "/n/gone/f.txt\n"); @@ -1741,6 +1796,373 @@ test "a root exec that fails logs its err against the pane it ran at" { try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)}))); } +test "Tty in a directory that is not there fails and makes no pane; a shell that never started answers a run" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); + // A host that starts shells (the core alone starts none, and asks + // nothing of a directory). + const Starting = struct { + fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} + }; + p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; + defer p.host = .{}; + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const refused = wr(p, Node.of(serial, .ctl), "Tty\n"); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "Tty: /tmp/pardes-no-such-dir-zz: no such directory"); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + // A terminal whose shell the host could not start. + _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Tty\n").reply.status); + const term = p.active; + p.shellFailed(@intCast(term), error.FileNotFound); + const run_node = Node.of(p.panes[term].?.serial, .pty_run); + const h = call(p, .{ .tag = 1, .op = .open, .node = run_node, .omode = 2 }).reply.handle; + _ = call(p, .{ .tag = 2, .op = .write, .node = run_node, .handle = h, .data = "true\n" }); + try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 3, .op = .read, .node = run_node, .handle = h, .size = 64 }).bytes); +} + +test "a builtin that needs its argument, written bare to an exec, is EINVAL as through ctl" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + for ([_]u64{ root_exec, Node.of(serialOf(p), .exec), @intFromEnum(tree.TopFile.tagexec) }) |node| { + const refused = wr(p, node, "Mount\n"); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectEqualStrings("wrong #args in control message \"Mount\"", refused.reply.ename); + } +} + +test "Grep with no hit says grep: no match and opens nothing, a missing pane directory passed over" { + 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 = "a.txt", .data = "alpha\n" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "other.txt", .data = "one\nbravo here\nthree bravo\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, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/a.txt\n", .{dir})); + // A hit opens its +Search, a row a line: exec reads it back. Find and + // Grep share it, a directory's one +Search, each run replacing it. + try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep bravo\n").reply.status); + const grepped = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; + try testing.expectEqual(@as(usize, 2), std.mem.count(u8, grepped.file.?.content, "other.txt:")); + try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:2") != null); + try testing.expect(std.mem.indexOf(u8, grepped.file.?.content, "other.txt:3") != null); + try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status); + const found = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?; + 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); + // 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"); + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n"); + try testing.expectEqual(E.NOENT, missed.errno()); + try testing.expectEqualStrings("grep: no match, not found", missed.reply.ename); + const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n"); + try testing.expectEqual(E.NOENT, unfound.errno()); + try testing.expectEqualStrings("find: no match, not found", unfound.reply.ename); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); +} + +test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const refused = wr(p, @intFromEnum(tree.TopFile.ctl), "Unmount nothere\n"); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "Unmount: no such mount nothere"); +} + +test "a terminal told the directory it already goes by logs no rename" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + _ = wr(p, Node.of(serialOf(p), .name), "/tmp/f.txt\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), "Tty\n").reply.status); + p.update(.tick); // announced + const term = p.active; + // The host says where its shell started: where it was already named. + p.setCwd(term, "/tmp"); + var rec: [32]u8 = undefined; + try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial}))); + // This process's directory, asked before the shell has said anything + // (so before its chdir): not taken. + const session = p.sessionDir(); + p.setCwd(term, session); + try testing.expect(!th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} ", .{p.panes[term].?.serial}))); + p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "$ " } }); + // Somewhere else is a rename. + p.setCwd(term, "/usr"); + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "rename {d} /usr", .{p.panes[term].?.serial}))); +} + +test "a terminal with text typed at its prompt is never typed into: a look at its directory opens another" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const term = p.paneBySerial(serialOf(p)).?; + p.setCwd(term, "/tmp"); + p.update(.{ .output = .{ .pane = @intCast(term), .bytes = "\x1b]133;A;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07TYPED" } }); + while (p.nextEffect()) |_| {} + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.look), "/tmp\n").reply.status); + var typed_into = false; + while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) { + typed_into = true; + }; + try testing.expect(!typed_into); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count + 1, after); + // An exec at it runs in a pane of its own, not typed after TYPED. + _ = wr(p, Node.of(serialOf(p), .exec), "echo hi\n"); + while (p.nextEffect()) |effect| if (effect == .write and effect.write.pane == term) { + typed_into = true; + }; + try testing.expect(!typed_into); +} + +test "a command line from a missing directory fails ENOENT and makes no pane; a command whose shell never started ends 127" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const Starting = struct { + fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {} + }; + p.host = .{ .vtable = &.{ .spawn = Starting.spawn } }; + defer p.host = .{}; + const serial = serialOf(p); + _ = wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); + var count: usize = 0; + for (p.panes) |slot| count += @intFromBool(slot != null); + const refused = wr(p, Node.of(serial, .exec), "echo hi\n"); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory"); + var after: usize = 0; + for (p.panes) |slot| after += @intFromBool(slot != null); + try testing.expectEqual(count, after); + // A command pane whose shell the host could not start. + _ = wr(p, Node.of(serial, .name), "/tmp/f.txt\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "echo hi\n").reply.status); + const cmd = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, Node.of(serial, .exec), 0, 64).bytes, "\n"), 10)).?; + p.shellFailed(@intCast(cmd), error.FileNotFound); + const pane = p.panes[cmd].?; + try testing.expect(pane.command_done); + try testing.expectEqual(@as(?u8, 127), pane.command_status); + var rec: [32]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "exit {d} 127\n", .{pane.serial}))); + try testing.expect(th.logHas(p, "shell: directory missing")); +} + +test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot start fails the write" { + 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 = .{}; + const serial = serialOf(p); + p.setCwd(p.paneBySerial(serial).?, "/tmp/pardes-no-such-dir-zz"); + const refused = wr(p, Node.of(serial, .pty_ctl), "exec\n"); + try testing.expectEqual(E.NOENT, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "exec: /tmp/pardes-no-such-dir-zz: no such directory"); + // The host's own failure is the waiting write's (late_failure). + p.fs.late_failure_len = 0; + p.shellFailed(@intCast(p.paneBySerial(serial).?), error.AccessDenied); + try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]); +} + +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(); + const serial = serialOf(p); + const log = @intFromEnum(tree.TopFile.log); + const h = call(p, .{ .tag = 1, .op = .open, .node = log, .omode = 2 }).reply.handle; + const Case = struct { node: u64, data: []const u8, words: []const u8, handle: u32 = 0 }; + for ([_]Case{ + .{ .node = root_exec, .data = "echo a\x7fb\n", .words = "control character" }, + .{ .node = root_look, .data = "a\x7fb\n", .words = "control character" }, + .{ .node = Node.of(serial, .name), .data = "\n", .words = "an empty name" }, + .{ .node = log, .data = "bogus\n", .words = "takes `follow`", .handle = h }, + }) |c| { + const refused = call(p, .{ .tag = 2, .op = .write, .node = c.node, .handle = c.handle, .data = c.data }); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c.words) != null); + } + _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); + try testing.expect(!th.logHas(p, "Invalid argument")); + try testing.expect(th.logHas(p, "an empty name")); +} + +test "/index shows a name as the log does: a newline in it is \\n, controls spaces, bytes not UTF-8 \\xNN" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const term = p.paneBySerial(serialOf(p)).?; + p.setCwd(term, "/tmp/two\nlines\x7f\xff"); + const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes; + try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines \\xff ") != null); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n")); + try testing.expectEqual(@as(u64, index.len), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size); + // Its tag never carries the raw newline either. + const tag = rd(p, Node.of(serialOf(p), .tag), 0, 4096).bytes; + try testing.expect(std.mem.indexOf(u8, tag, "/tmp/two\\nlines") != null); + // A backslash of the name's own is doubled: `\n` written in a name + // reads back unlike a newline. + p.setCwd(term, "/tmp/back\\nslash"); + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null); +} + +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(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "hit.txt", .data = "needle here\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, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/other.txt\n", .{dir})); + // A pane of the served tree. + const virtual = try th.newPane(p); + _ = wr(p, Node.of(virtual, .name), "/virtual/index\n"); + // One named the served tree's root, whose directory is `/`. + const top = try th.newPane(p); + _ = wr(p, Node.of(top, .name), "/virtual\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Grep needle\n").reply.status); + // `.` from a pane whose directory is gone. + const gone = try th.newPane(p); + _ = wr(p, Node.of(gone, .name), "/tmp/pardes-no-such-dir-zz/f.txt\n"); + const looked = wr(p, Node.of(gone, .look), ".\n"); + try testing.expectEqual(E.NOENT, looked.errno()); + try testing.expectStringStartsWith(looked.reply.ename, "look: /tmp/pardes-no-such-dir-zz: no such directory"); +} + +test "a tag write refuses NUL and the other control characters" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + for ([_]u64{ @intFromEnum(tree.TopFile.tag), tree.Node.ofCol(pardes.layout.columnSerial(p, 0), .tag), Node.of(serialOf(p), .tag) }) |node| { + const refused = wr(p, node, " a\x00b"); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "invalid tag text"); + try testing.expectEqual(E.INVAL, wr(p, node, " \x7f").errno()); + } +} + +test "a malformed address is bad address syntax, on a terminal as on a text" { + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + const t = try withFile(testing.allocator, "one\n"); + defer t.deinit(); + for ([_]*pardes.Pardes{ p, t }) |q| { + for ([_][]const u8{ "1x", "/(/", ",,x" }) |expr| { + const refused = wr(q, Node.of(serialOf(q), .addr), expr); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expect(refused.reply.ename.len > 0); + } + try testing.expectEqualStrings("bad address syntax", wr(q, Node.of(serialOf(q), .addr), "1x").reply.ename); + } +} + +test "a bad event record says what is wrong with it" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const event = Node.of(serialOf(p), .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle; + for ([_][2][]const u8{ + .{ "MI0 1 0 1 x\n", "only a look or exec" }, + .{ "MX3 1 0 0 \n", "ends before it starts" }, + .{ "MX9 9 0 0 \n", "range past end" }, + .{ "garbage\n", "a record is" }, + }) |c| { + const refused = call(p, .{ .tag = 2, .op = .write, .node = event, .handle = h, .data = c[0] }); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null); + } + _ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h }); +} + +test "a column takes panes until their documented minimums fill it, not until none can be halved" { + const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 50 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const new = @intFromEnum(tree.TopFile.new); + while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync(); + p.sync(); + const avail = p.screen_h - (p.topBarHeight() + p.columnBarHeight()); + for (0..p.ncol) |c| { + var need: u32 = 0; + for (p.col_panes[c][0..p.col_n[c]]) |k| need += layout.minRows(p, k); + // Refused only when one more pane's tag and two rows would not fit. + if (p.col_n[c] > 1) try testing.expect(avail - need < 3); + // Every pane keeps its minimum. + for (p.col_panes[c][0..p.col_n[c]]) |k| try testing.expect(p.rects[k].h >= layout.minRows(p, k)); + } +} + +test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" { + 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 = "a.txt", .data = "one\ntwo\nthree\n" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\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 a_path = try std.fmt.bufPrint(&line, "{s}/a.txt", .{dir}); + var a_buf: [4200]u8 = undefined; + const a = a_buf[0..a_path.len]; + @memcpy(a, a_path); + // Opened, its dot on line 3, then closed. + try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}:3\n", .{a})).reply.status); + p.sync(); + const opened = p.active; + try testing.expectEqualStrings(a, p.panes[opened].?.file.?.path); + var want: [4300]u8 = undefined; + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{a})) != null); + // Another file, so the jumplist has somewhere to come back from. + try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir})).reply.status); + p.sync(); + try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[opened].?.serial, .ctl), "Del\n").reply.status); + p.sync(); + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "closed {s}\n", .{a})) != null); + // Recent lists it, closed, at its dot; a look at the row opens it there. + try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status); + 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); + p.sync(); + const back = p.panes[p.active].?; + try testing.expectEqualStrings(a, back.file.?.path); + try testing.expectEqual(@as(i32, 2), back.body.cur_row); + // Closed again; the jumplist keeps its entry, closed, and a jump back + // to it opens it again. + _ = wr(p, Node.of(back.serial, .ctl), "Del\n"); + p.sync(); + var closed_entry = false; + for (p.jumps[0..p.njumps]) |j| closed_entry = closed_entry or j.serial == 0; + try testing.expect(closed_entry); + var tries: usize = 0; + while (tries < p.njumps) : (tries += 1) { + p.jumpBy(-1); + p.sync(); + if (p.panes[p.active].?.file) |f| if (std.mem.eql(u8, f.path, a)) break; + } + try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path); +} + 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(); @@ -2098,4 +2520,10 @@ test "@p<terminal>:<address> takes any address over the terminal's lines, select try testing.expectEqual(@as(i32, 1), pane.body.cur_col); _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial})); try testing.expect(th.logHas(p, "look: no match for regexp")); + // A line past its last is a miss, as in a file. + const row_before = pane.body.cur_row; + _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:999\n", .{serial})); + try testing.expect(th.logHas(p, "has no line 999")); + try testing.expectEqual(row_before, pane.body.cur_row); + try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes); } diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 98799774..690efbe5 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -101,7 +101,7 @@ pub const Queue = struct { /// One record per read; `.again` holds the read until a record arrives. pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; - if (req.size < record.len) return Reply.fail(req.tag, E.INVAL); + if (req.size < record.len) return tree.failText(req.tag, E.INVAL, "invalid read: shorter than the record waiting"); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, record) catch return Reply.fail(req.tag, E.NOMEM); q.pop(); @@ -140,14 +140,18 @@ pub fn announce(p: *Pardes) void { if (!pane.fs.unannounced) continue; pane.fs.unannounced = false; noteLog(p, .new, pane); + // A file opened (a look, a Restore, a boot): first in Recent. + if (pane.file) |f| if (f.output == null) p.recent.opened(p.recentGpa(), f.path); } } /// Records `<kind> <serial> <name>`. pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void { - var buf: [4096 + 64]u8 = undefined; - const name = pane_files.nameOf(pane); - pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name[0..@min(name.len, 4096)] }) catch return); + var buf: [4 * 4096 + 64]u8 = undefined; + var name_buf: [4 * 4096]u8 = undefined; + // As /index shows it: a newline in the name is `\n`. + const name = shown(pane_files.nameOf(pane), &name_buf); + pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return); } /// Records `msg <serial> <text>` for what the editor said, `-` for no pane. @@ -214,9 +218,12 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void { }; } var buf: [256]u8 = undefined; + // A reason that names its file already (`look: no space...` from a + // look) is not prefixed twice: `err 3 look: no space...`. + const reason = if (why.len > file.len + 2 and std.ascii.startsWithIgnoreCase(why, file) and std.mem.startsWith(u8, why[file.len..], ": ")) why[file.len + 2 ..] else why; // A long reason is cut between words, with an ellipsis, never mid-word. - const kept = pardes.Messages.clip(why, 200); - const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < why.len) "…" else "" }) catch return; + const kept = pardes.Messages.clip(reason, 200); + const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < reason.len) "…" else "" }) catch return; for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') { c.* = ' '; }; @@ -292,11 +299,13 @@ pub fn dropMessage(p: *Pardes, text: []const u8) void { /// counts that one instead: `<record> (x2)`, the count being every time it /// was said. One a follower has already read is not rewritten: the repeat /// is a new line carrying the running count, `(x3)`. -fn pushCounting(p: *Pardes, record: []u8) void { +fn pushCounting(p: *Pardes, raw: []u8) void { // A client retrying a write that fails the same way would fill the ring // with one line, so a repeat of the newest record is that record counted, // `(x3)`, as +Messages counts its repeats; unless a follower has read it // already and so waits on the repeat as a line of its own. + var clean: [4 * 4096 + 256]u8 = undefined; + const record = sanitize(raw, &clean); const last = newest(p) orelse return pushLog(p, record); var said = last.text; var times: u32 = 1; @@ -340,10 +349,75 @@ fn followerRead(p: *Pardes, seq: u64) bool { /// The log is one ring that records whether or not anyone reads it. A record /// is one line: a newline in a message or a name would read as two records. -fn pushLog(p: *Pardes, record: []u8) void { - for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') { - c.* = ' '; - }; +/// A record as one line of text (shown): the record's own newline, last, +/// is kept. +fn sanitize(record: []const u8, out: []u8) []u8 { + // A newline in a message is a space (its words go on); a name's own + // newline is escaped before it gets here (noteLog). + const w = shownAs(record[0 .. record.len - 1], out[0 .. out.len - 1], false).len; + out[w] = '\n'; + return out[0 .. w + 1]; +} + +/// `text` as one line of UTF-8 a reader can split and decode, as /log and +/// /index show names: a backslash is `\\`, a newline `\n` (a name with one +/// stays readable as what it is, not run into the next), any other control +/// character, DEL or C1 control (U+0080-U+009F) a space, and a byte that +/// is not UTF-8 `\xNN`. `out` of 4 bytes a byte of `text` holds it all. +pub fn shown(text: []const u8, out: []u8) []u8 { + return shownAs(text, out, true); +} + +fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { + var w: usize = 0; + var i: usize = 0; + const body = text; + while (i < body.len and w + 4 <= out.len) { + const c = body[i]; + // A name's own backslash is `\\`, so `\n` and `\xNN` read back as + // the escapes they are, never as a name that held them. + if (c == '\\' and escape_newline) { + @memcpy(out[w..][0..2], "\\\\"); + w += 2; + i += 1; + continue; + } + if (c == '\n' and escape_newline) { + @memcpy(out[w..][0..2], "\\n"); + w += 2; + i += 1; + continue; + } + if (c < ' ' or c == 0x7f) { + out[w] = ' '; + w += 1; + i += 1; + continue; + } + const n = std.unicode.utf8ByteSequenceLength(c) catch 0; + if (n == 0 or i + n > body.len or !std.unicode.utf8ValidateSlice(body[i .. i + n])) { + _ = std.fmt.bufPrint(out[w..], "\\x{x:0>2}", .{c}) catch break; + w += 4; + i += 1; + continue; + } + if (n == 2 and c == 0xC2 and body[i + 1] <= 0x9F) { + out[w] = ' '; + w += 1; + i += 2; + continue; + } + if (w + n > out.len) break; + @memcpy(out[w..][0..n], body[i..][0..n]); + w += n; + i += n; + } + return out[0..w]; +} + +fn pushLog(p: *Pardes, raw: []u8) void { + var clean: [4 * 4096 + 256]u8 = undefined; + const record = sanitize(raw, &clean); // One record larger than the ring would push every other out and then // not fit itself; cut it to what fits instead, on a character boundary. var end = @min(record.len, p.fs.log.cap - 4) - 1; @@ -438,7 +512,7 @@ pub fn writeLog(p: *Pardes, req: Req) Reply { // what was there and waits for what comes after, as tail -n0 -f does. if (std.mem.eql(u8, word, "follow new")) { slot.pos = slot.bytes.len; - } else if (!std.mem.eql(u8, word, "follow")) return Reply.fail(req.tag, E.INVAL); + } else if (!std.mem.eql(u8, word, "follow")) return tree.failText(req.tag, E.INVAL, "invalid write to log: it takes `follow` or `follow new`"); slot.follow = true; return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -654,14 +728,14 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply { while (check.next()) |r| { switch (r.action) { .body_look, .tag_look, .body_exec, .tag_exec => {}, - else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event), + else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": only a look or exec (L l X x) is written back"), } const n = if (r.action.onTag()) tag.len else body.len; - if (r.q0 > r.q1) return tree.failText(req.tag, E.INVAL, tree.e_bad_event); + if (r.q0 > r.q1) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": its range ends before it starts"); // Well formed, but not a range of this text: say which. if (r.q1 > n) return tree.failText(req.tag, E.INVAL, if (r.action.onTag()) "range past end of tag" else "range past end of body"); } - if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event); + if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": a record is <origin><action><q0> <q1> <flag> <count> <text>, a line each"); } var run: EventReader = .{ .data = req.data }; while (run.next()) |r| { @@ -1244,6 +1318,17 @@ test "a long msg record is cut between words at its cap, with an ellipsis" { _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } +test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are escaped" { + var out: [64]u8 = undefined; + var name: [64]u8 = undefined; + try testing.expectEqualStrings("/tmp/two\\nlines", shown("/tmp/two\nlines", &name)); + // A backslash of the name's own is doubled: `\n` in a name differs from + // a newline in one. + try testing.expectEqualStrings("/tmp/a\\\\nb", shown("/tmp/a\\nb", &name)); + try testing.expectEqualStrings("msg - a b c \\xff d\n", sanitize("msg - a\x7fb\xc2\x85c \xff d\n", &out)); + try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out)); +} + test "a long err record is cut between words, with an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index d8329ccc..4370d401 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -298,7 +298,8 @@ pub fn indexLen(p: *Pardes) u64 { const pane = p.panes[p.paneBySerial(serial).?].?; var digits: [16]u8 = undefined; n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len; - n += 1 + kindOf(pane).len + 3 + nameOf(pane).len + 1; + var name_buf: [4 * 4096]u8 = undefined; + n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1; n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } return n; @@ -457,8 +458,21 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } +/// Why `data` is no text a tag takes: a control character but a tab or a +/// newline, DEL, a C1 control, or bytes that are not UTF-8 (a NUL in a tag +/// made a Dump that wrote nothing). +pub fn tagFault(data: []const u8) ?[]const u8 { + for (data) |c| if ((c < ' ' and c != '\t' and c != '\n') or c == 0x7f) return "invalid tag text: a control character"; + if (!std.unicode.utf8ValidateSlice(data)) return "invalid tag text: not UTF-8"; + var i: usize = 0; + while (std.mem.indexOfScalarPos(u8, data, i, 0xC2)) |at| : (i = at + 1) + if (at + 1 < data.len and data[at + 1] <= 0x9F) return "invalid tag text: a control character"; + return null; +} + fn writeTag(req: Req, pane: *Pane) Reply { if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 }; + if (tagFault(req.data)) |why| return tree.failText(req.tag, E.INVAL, why); // The tag's own text grows by what is written, newlines and all: a tag // is a text like any other (acme's tag file appends the same way). const pf = &pane.fs; @@ -481,8 +495,11 @@ fn writeTag(req: Req, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } +/// A write only text takes, to a pane with none: said, as every EINVAL is. +pub const e_no_text = "invalid write: this pane has no text (a terminal, an image or a PDF)"; + fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); const pf = &pane.fs; if (pf.addr_failed) return tree.failText(req.tag, E.INVAL, e_addr_failed); clampAddr(pf, bodyOf(pane).len); @@ -501,7 +518,7 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply { } fn writeSel(p: *Pardes, req: Req, pane: *Pane) Reply { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); const d = dotOf(pane); const q0: usize = d.q0; const q1: usize = @max(q0, @as(usize, d.q1)); @@ -558,7 +575,7 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { .limit => pf.limit = range, // Setting dot scrolls to it, which is the whole of acme's `show`. .dot => { - if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + if (fileOf(pane) == null) return tree.failText(req.tag, E.INVAL, e_no_text); setDot(pane, range); }, else => unreachable, @@ -573,7 +590,7 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { const pf = &pane.fs; switch (file) { .dirty => { - const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL); + const f = fileOf(pane) orelse return tree.failText(req.tag, E.INVAL, e_no_text); f.saved_revision = if (on) f.revision -% 1 else f.revision; }, // acme's nomark joins the writes after it into one undo step. The @@ -597,19 +614,34 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { /// quietly cut off, so the name a script wrote is the name it gets. const e_name_char = "bad character in file name"; +/// Why `name` is not one file name, with the reason, or null: a newline, a +/// control byte, DEL or a C1 control (U+0080-U+009F), a blank at either +/// end, or bytes that are not UTF-8. +fn nameFault(name: []const u8) ?[]const u8 { + if (std.mem.indexOfScalar(u8, name, '\n') != null) return e_name_char ++ ": a newline (a name is one line)"; + for (name) |c| if (c < ' ' or c == 0x7f) return e_name_char ++ ": a control character"; + if (!std.unicode.utf8ValidateSlice(name)) return e_name_char ++ ": not UTF-8"; + if (std.mem.indexOf(u8, name, "\xc2") != null) { + var i: usize = 0; + while (std.mem.indexOfScalarPos(u8, name, i, 0xC2)) |at| : (i = at + 1) + if (at + 1 < name.len and name[at + 1] <= 0x9F) return e_name_char ++ ": a control character"; + } + if (name[0] == ' ' or name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end"; + return null; +} + fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - // One line: its newline ends it, as `echo` writes it. + // One name: its newline ends it, as `echo` writes it, and it is one. const name = if (std.mem.endsWith(u8, req.data, "\n")) req.data[0 .. req.data.len - 1] else req.data; - if (name.len == 0) return Reply.fail(req.tag, E.INVAL); - for (name) |c| if (c < ' ') return tree.failText(req.tag, E.INVAL, e_name_char); - if (name[0] == ' ' or name[name.len - 1] == ' ') return tree.failText(req.tag, E.INVAL, e_name_char); + if (name.len == 0) return tree.failText(req.tag, E.INVAL, e_name_char ++ ": an empty name"); + if (nameFault(name)) |why| return tree.failText(req.tag, E.INVAL, why); if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal()) "rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another" else "rename not allowed: an image or PDF is named by the file it shows"); const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch return Reply.fail(req.tag, E.NOMEM); - if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return Reply.fail(req.tag, E.INVAL); + if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be"); nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) { error.OutOfMemory => E.NOMEM, else => E.INVAL, @@ -636,7 +668,9 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) ! if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; } - f.saved_revision = f.revision -% 1; + // A name alone is no edit: dirty stays what the text made it, as + // the guards ask only whether it was ever edited, so /index, Exit + // and Restore agree. Save writes it under the new name all the same. f.watch_after_save = fs.localPath(full) != null; } if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); @@ -649,6 +683,7 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) ! look.invalidateLookHover(p, id); p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); events.noteLog(p, .rename, pane); + if (f.output == null) p.recent.opened(p.recentGpa(), f.path); } /// Diagnostics go to the +Errors buffer of the pane's directory. @@ -1103,9 +1138,26 @@ test "name reads the file name and writing it promotes a scratch without touchin try testing.expectEqualStrings("existing target\n", target); for ([_][]const u8{ "", "\n", "bad\x01name\n" }) |bad| try testing.expectEqual(E.INVAL, wr(p, name, bad).errno()); - // A blank at either end is refused as acme refuses one, not cut off. - for ([_][]const u8{ "trailing.zig \n", " leading.zig\n", "tab\t.zig\n" }) |bad| - try testing.expectEqualStrings("bad character in file name", wr(p, name, bad).reply.ename); + // One name, in acme's words and why: a blank at either end (not cut + // off), a second line, a control byte, DEL, a C1 control, not UTF-8. + for ([_][2][]const u8{ + .{ "trailing.zig \n", "a blank at its end" }, + .{ " leading.zig\n", "a blank at its end" }, + .{ "tab\t.zig\n", "a control character" }, + .{ "two\nlines\n", "a newline" }, + .{ "del\x7f.zig\n", "a control character" }, + .{ "c1\xc2\x85.zig\n", "a control character" }, + .{ "bad\xff.zig\n", "not UTF-8" }, + }) |c| { + const refused = wr(p, name, c[0]); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectStringStartsWith(refused.reply.ename, "bad character in file name: "); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, c[1]) != null); + } + // A name that is one line and UTF-8 is taken, é and all. + try testing.expectEqual(Status.ok, wr(p, name, "caf\xc3\xa9.zig\n").reply.status); + try testing.expect(std.mem.endsWith(u8, pane.file.?.path, "/caf\xc3\xa9.zig")); + try testing.expectEqual(Status.ok, wr(p, name, try std.fmt.bufPrint(&line, "{s}\n", .{path})).reply.status); try testing.expectEqualStrings(path, pane.file.?.path); try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status); const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory}); @@ -1151,7 +1203,7 @@ test "relative names use the file directory and can name a new target" { var expected_buf: [4096]u8 = undefined; const expected = try std.fmt.bufPrint(&expected_buf, "{s}/created.txt", .{directory}); try testing.expectEqualStrings(expected, pane.file.?.path); - try testing.expect(dirtyOf(pane)); + try testing.expect(!dirtyOf(pane)); // a name alone is no edit } test "relative names follow a pane/new scratch's session directory and virtual directories" { diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index 4009af49..aa0f499a 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -61,6 +61,12 @@ 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; + // A shell restarted where the directory is gone would not start: + // refused before anything runs, as Tty there is. + if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| if (pardes.exec.missingDir(p, pane.cwdSlice())) { + const dir = pane.cwdSlice(); + return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}: no such directory", .{dir[0..@min(dir.len, 256)]}) catch "exec: no such directory"); + }; if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl); } } @@ -196,7 +202,10 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { 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 (pane.command != null) { + if (pane.shell_failed) { + // Its shell never started: no prompt is coming to wait for. + answer(p, slot, "error shell gone", .{}); + } else 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) { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index ed0e0352..64089c01 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -219,6 +219,9 @@ pub const TopFile = enum(u5) { tag, col, tagexec, + /// The files opened lately, closed ones too, a line each: + /// `open|closed <path>`, most recent first. + recent, pub fn fileName(f: TopFile) []const u8 { return if (f == .root) "/" else @tagName(f); @@ -228,7 +231,7 @@ pub const TopFile = enum(u5) { return switch (f) { .root, .pane, .col => 0o755, .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666, - .README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444, + .README, .index, .status, .screen, .listeners, .new, .commands, .layout, .recent => 0o444, }; } @@ -239,6 +242,13 @@ pub const TopFile = enum(u5) { pub const root: u64 = @intFromEnum(TopFile.root); +fn recentLen(p: *Pardes) u64 { + var buf: [64]u8 = undefined; + var counting: std.Io.Writer.Discarding = .init(&buf); + pardes.panes.Output.recentText(p, &counting.writer, false) catch return 0; + return counting.fullCount(); +} + /// Declaration order is readdir order. pub const PaneFile = enum(u5) { dir = 0, @@ -556,6 +566,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { }, .ctl => ctl.rootLen(p), .commands => ctl.commandsLen(), + .recent => recentLen(p), .focus => focus: { if (p.header_focus) break :focus 0; const pn = p.panes[p.active] orelse break :focus 0; @@ -653,7 +664,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { .root => { // The guide, then what describes the session, then the // files that act, then the directories. - inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.recent, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); @@ -917,7 +928,11 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { last = serial; const id = p.paneBySerial(serial).?; const pn = p.panes[id].?; - out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), pane.nameOf(pn), pane.columnOf(p, id) }) catch {}; + // A name as /log shows it: one line of UTF-8, a + // newline in it `\n` (events.shown). + var name_buf: [4 * 4096]u8 = undefined; + const name = events.shown(pane.nameOf(pn), &name_buf); + out.print(p.gpa, "{d} {s} {d} {s} {d}\n", .{ serial, pane.kindOf(pn), @intFromBool(pane.dirtyOf(pn)), name, pane.columnOf(p, id) }) catch {}; } break :index stagedReply(p, req); }, @@ -929,6 +944,12 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { .focus => ctl.readFocus(p, req), .ctl => ctl.readRoot(p, req), .commands => ctl.readCommands(p, req), + .recent => recent: { + var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); + pardes.panes.Output.recentText(p, &text.writer, false) catch break :recent Reply.fail(req.tag, E.NOMEM); + p.fs.stage(p.gpa).appendSlice(p.gpa, text.written()) catch break :recent Reply.fail(req.tag, E.NOMEM); + break :recent stagedReply(p, req); + }, .layout => layout: { var text: std.Io.Writer.Allocating = .init(p.scratch.allocator()); cols.writeLayout(p, &text.writer) catch break :layout Reply.fail(req.tag, E.NOMEM); @@ -1007,7 +1028,7 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target); // A clicked line holds no control character: refused at once, not held // to fail unseen at the close. - if (resultsFile(target)) for (req.data) |c| if (c < ' ' and c != '\t' and c != '\n' and c != '\r') return Reply.fail(req.tag, E.INVAL); + if (resultsFile(target)) for (req.data) |c| if ((c < ' ' and c != '\t' and c != '\n' and c != '\r') or c == 0x7f) return failText(req.tag, E.INVAL, ctl.e_control); o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM); const end = ctl.completeEnd(p, o.pending.items); if (end == 0) { @@ -1197,13 +1218,13 @@ test "readdir lists the root and a pane directory without creating anything" { @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); try testing.expect(top.len >= 10); - for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { + for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "recent", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 14, top[i].dir); + try testing.expectEqual(i >= 15, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node); - try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node); - try testing.expectEqual(fs.os_root, top[16].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[15].node); + try testing.expectEqual(@intFromEnum(TopFile.col), top[16].node); + try testing.expectEqual(fs.os_root, top[17].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); @@ -1466,6 +1487,9 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e const looked = th.wr(p, look_node, try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{dir})); try testing.expect(looked.errno() != 0); try testing.expect(std.mem.indexOf(u8, looked.reply.ename, said) != null); + // Its err record names the file once: `look: no space...`. + try testing.expect(th.logHas(p, "look: no space")); + try testing.expect(!th.logHas(p, "look: look:")); try testing.expectEqualStrings("", th.rd(p, look_node, 0, 64).bytes); // New from a pane's ctl fails as well. const ctl_node = Node.of(p.panes[0].?.serial, .ctl); |
