diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/addr.zig | 13 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 128 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 72 | ||||
| -rw-r--r-- | src/ninep/events.zig | 52 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 221 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 22 |
6 files changed, 448 insertions, 60 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index 33a3f646..9674a323 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -304,6 +304,7 @@ pub const Addr = struct { error.TooLong => regexp_.Regex.e_long, error.WideRange => regexp_.Regex.e_wide, error.NegatedRunes => regexp_.Regex.e_negated, + error.Mixed => regexp_.Regex.e_mixed, error.Bad => e_regexp, }; return null; @@ -383,6 +384,18 @@ test "an address is evaluated from the current one, as acme's are" { try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename); } +test "an alternation anchoring some branches and not others is refused with why, a $ not counting" { + const p = try th.withFile(testing.allocator, "def\nfoo\n"); + defer p.deinit(); + const node = Node.of(th.serialOf(p), .addr); + for ([_][]const u8{ "/^def|x/", "/foo$|^zèbre/" }) |pat| { + const r = th.wr(p, node, pat); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings(regexp_.Regex.e_mixed, r.reply.ename); + } + try testing.expectEqual(Status.ok, th.wr(p, node, "/foo$|def/").reply.status); +} + test "a pattern with a newline and an anchor mvzr would misread is refused EINVAL, by addr and by Edit" { const p = try th.withFile(testing.allocator, "a\nfoo\n"); defer p.deinit(); diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index cc583c93..454cce4a 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -7,6 +7,7 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const Pardes = pardes.Pardes; const tree = @import("tree.zig"); +const limits = @import("../memory.zig").limits; const tagline = pardes.tagline; const layout = pardes.layout; const Text = pardes.panes.Text; @@ -65,10 +66,11 @@ pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 { } pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply { + applyTruncation(p, serial); const text = headerText(p, serial) orelse return Reply.fail(req.tag, E.NOENT); const out = p.fs.stage(p.gpa); + // As a pane tag reads: the text alone, no newline after it. out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); - out.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } @@ -78,7 +80,19 @@ 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 pending = p.fs.header_trunc_pending == rewriteKey(serial); + // The same checks as a pane tag's, whole or not at all; a refused + // write drops the truncation it would have come with. + if (pardes.ctlfs.pane.tagFault(req.data)) |why| { + if (pending) p.fs.header_trunc_pending = null; + return tree.failText(req.tag, E.INVAL, why); + } + const had = if (pending) 0 else headerText(p, serial).?.len; + if (req.data.len > limits.max_tag_tail -| had) { + if (pending) p.fs.header_trunc_pending = null; + return tree.failText(req.tag, E.NOSPC, pardes.ctlfs.pane.e_tag_over); + } + applyTruncation(p, serial); const was = headerText(p, serial).?; var data = req.data; const rewriting = p.fs.header_rewrite == rewriteKey(serial); @@ -99,6 +113,20 @@ pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply { /// Truncating clears it, as a pane tag's `cleartag`: its default words go /// too, and `u` in it brings them back. pub fn truncate(p: *Pardes, serial: ?u32) tree.Status { + if (header(p, serial) == null) return .err; + // Done with the write after it, which may yet be refused. + p.fs.header_trunc_pending = rewriteKey(serial); + return .ok; +} + +/// A pending truncation of this header, done. +pub fn applyTruncation(p: *Pardes, serial: ?u32) void { + if (p.fs.header_trunc_pending != rewriteKey(serial)) return; + p.fs.header_trunc_pending = null; + _ = clear(p, serial); +} + +fn clear(p: *Pardes, serial: ?u32) tree.Status { const t = (header(p, serial) orelse return .err).text; const empty = p.gpa.alloc(u8, 0) catch return .err; t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null); @@ -118,6 +146,7 @@ fn rewriteKey(serial: ?u32) u32 { } pub fn released(p: *Pardes, serial: ?u32) void { + applyTruncation(p, serial); if (p.fs.header_rewrite != rewriteKey(serial)) return; p.fs.header_rewrite = null; p.fs.header_held = false; @@ -176,7 +205,8 @@ pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply { 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); + // With no pane at all, a word runs at the stand-in, as a click in + // the empty window's tag does (runBuiltinFrom). p.exec_column = col; defer p.exec_column = null; p.exec_header = true; @@ -198,6 +228,19 @@ 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 "a tagexec word that makes, acts on and focuses no pane reads back nothing" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status); + try testing.expectEqualStrings("", th.rd(p, tagexec, 0, 64).bytes); + // A column's New makes one, and answers it. + const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); + try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "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, col_exec, 0, 64).bytes); +} + 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(); @@ -218,6 +261,8 @@ fn paneWord(line: []const u8, own: []const u8) bool { 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; + // A new pane is every tag's to make, in the session's directory. + if (b == .New or b == .Tty) return false; var words = std.mem.tokenizeScalar(u8, own, ' '); while (words.next()) |w| if (std.mem.eql(u8, w, word)) return false; return true; @@ -238,6 +283,47 @@ test "a pane's word written to /tagexec or a column's exec is refused, and acts try testing.expectEqual(tree.Status.ok, th.wr(p, col_exec, "New\n").reply.status); } +test "Help and Find at /tagexec act in the session's directory, not the keyboard pane's" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "needle.txt", .data = "n\n" }); + var buf: [4096]u8 = undefined; + const session = buf[0..try tmp.dir.realPath(testing.io, &buf)]; + @memcpy(p.session_dir[0..session.len], session); + p.session_dir_len = @intCast(session.len); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + for ([_][]const u8{ "Help\n", "Find needle\n" }) |line| { + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, line).reply.status); + const made = p.panes[p.active].?.file.?.path; + try testing.expectEqualStrings(session, std.fs.path.dirname(made).?); + } +} + +test "New and Tty run at /tagexec and a column's exec, with no pane at all too" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const tagexec = @intFromEnum(tree.TopFile.tagexec); + const col_exec = tree.Node.ofCol(layout.columnSerial(p, 0), .exec); + for ([_]u64{ tagexec, col_exec }) |node| for ([_][]const u8{ "New\n", "Tty\n" }) |line| { + const before = p.next_serial; + try testing.expectEqual(tree.Status.ok, th.wr(p, node, line).reply.status); + try testing.expectEqual(before + 1, p.next_serial); + }; + // Every pane gone: the workspace tag makes one all the same. + for ([_][]const u8{ "New\n", "Tty\n" }) |line| { + while (p.ncol > 0) { + const first = p.panes[p.col_panes[0][0]].?.serial; + _ = th.wr(p, tree.Node.of(first, .ctl), "delete\n"); + if (p.anyPane() == null) break; + } + try testing.expect(p.anyPane() == null); + try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, line).reply.status); + try testing.expect(p.anyPane() != null); + } +} + 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(); @@ -250,6 +336,7 @@ test "every pane word a tag no pane owns does not hold is refused there, Edit in var checked: usize = 0; for (std.enums.values(Builtin)) |b| { if (pardes.builtins.registry.scope(b) == .session) continue; + if (b == .New or b == .Tty) continue; // every tag's 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)); @@ -321,15 +408,15 @@ test "a column's tag and the workspace's read, replace and append as a pane tag try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "777").errno()); const tag = Node.ofCol(serial, .tag); try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(serial, .dir), "tag").reply.status); - try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol\n", th.rd(p, tag, 0, 4096).bytes); + try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", th.rd(p, tag, 0, 4096).bytes); // `echo Make > tag`: cleared, then the newline ending the write dropped. _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); _ = th.wr(p, tag, "Make\n"); - try testing.expectEqualStrings("Make\n", th.rd(p, tag, 0, 4096).bytes); + try testing.expectEqualStrings("Make", th.rd(p, tag, 0, 4096).bytes); _ = th.call(p, .{ .tag = 2, .op = .release, .node = tag }); // `>>` appends; a newline in a one-line tag is a space. _ = th.wr(p, tag, " Lint\nNow"); - try testing.expectEqualStrings("Make Lint Now\n", th.rd(p, tag, 0, 4096).bytes); + try testing.expectEqualStrings("Make Lint Now", th.rd(p, tag, 0, 4096).bytes); const top = @intFromEnum(tree.TopFile.tag); _ = th.call(p, .{ .tag = 3, .op = .setattr, .node = top, .truncate = true }); _ = th.wr(p, top, "Newcol Exit\n"); @@ -356,6 +443,35 @@ test "a focus write makes its pane's column the active one: layout says so and p } } +test "a refused write after a truncation leaves every kind of tag as it was, and Dump still works" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = th.serialOf(p); + const col = layout.columnSerial(p, 0); + const big = "w" ** (limits.max_tag_tail + 1); + // Each tag: a truncating open, then a write too long: nothing changes. + for ([_]u64{ Node.of(serial, .tag), Node.ofCol(col, .tag), @intFromEnum(tree.TopFile.tag) }) |node| { + _ = th.wr(p, node, " Mine"); + const before = try testing.allocator.dupe(u8, th.rd(p, node, 0, 1 << 16).bytes); + defer testing.allocator.free(before); + _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = node, .truncate = true }); + const h = th.call(p, .{ .tag = 2, .op = .open, .node = node, .omode = 1 }).reply.handle; + const r = th.call(p, .{ .tag = 3, .op = .write, .node = node, .handle = h, .data = big }); + try testing.expectEqual(E.NOSPC, r.errno()); + try testing.expectEqualStrings("tag: no space: over 4096 bytes", r.reply.ename); + _ = th.call(p, .{ .tag = 4, .op = .release, .node = node, .handle = h, .opened = true }); + try testing.expectEqualStrings(before, th.rd(p, node, 0, 1 << 16).bytes); + // A control character is refused the same way on every kind. + try testing.expectEqual(E.INVAL, th.wr(p, node, "a\x01b").errno()); + // A truncation with no write after it clears at the close. + _ = th.call(p, .{ .tag = 5, .op = .setattr, .node = node, .truncate = true }); + const e = th.call(p, .{ .tag = 6, .op = .open, .node = node, .omode = 1 }).reply.handle; + _ = th.call(p, .{ .tag = 7, .op = .release, .node = node, .handle = e, .opened = true }); + try testing.expect(std.mem.indexOf(u8, th.rd(p, node, 0, 1 << 16).bytes, "Mine") == null); + } + try pardes.dump.dumpState(p); +} + test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5ed03c58..0bbc0319 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -93,6 +93,12 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) vo // 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); + // A word in a column's or the workspace's tag ran at no pane: it + // answers one only if it focused one (Newcol answers nothing). + if (p.exec_header) { + if (p.active != id) if (p.panes[p.active]) |pane| noteResult(p, pane.serial); + return; + } noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial); } @@ -233,6 +239,14 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i p.fs.results_len = 0; return refuse(p, req, "wrong #args in control message", cmd); }; + // ...and one that takes none, written with one (`Config extra`): + // refused as a ctl's is, not run as a shell command of that name. + if (std.mem.indexOfAny(u8, cmd, " \t")) |sp| if (std.meta.stringToEnum(Builtin, cmd[0..sp])) |b| { + if (!builtins.registry.takesArg(b) and config.Runtime.find(cmd[0..sp]) == null) { + p.fs.results_len = 0; + return refuse(p, req, "wrong #args in control message", cmd); + } + }; } p.fs.capturing = true; p.fs.failure_len = 0; @@ -372,17 +386,27 @@ const Builtin = builtins.registry.Builtin(); /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { const room = p.fs.ename.len -| (why.len + 3); - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, line[0..@min(line.len, room)] }) catch why; + var shown: [320]u8 = undefined; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, blanked(line[0..@min(line.len, room)], &shown) }) catch why; return tree.failText(req.tag, E.INVAL, text); } /// `refuse`, saying which ctl takes the message instead. 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; + var shown: [320]u8 = undefined; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(line[0..@min(line.len, room)], &shown), ctl }) catch why; return tree.failText(req.tag, E.INVAL, text); } +/// A quoted line as a refusal shows it: a control byte (a tab, the one a +/// line may hold) is a blank, so the reason reads as one line of words. +fn blanked(line: []const u8, buf: *[320]u8) []const u8 { + const n = @min(line.len, buf.len); + for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c; + return buf[0..n]; +} + /// Checks one line written to a ctl as a builtin of `scope` before any line /// of the write runs: a word the registry knows, of this ctl's scope, given /// an argument if and only if it takes or requires one, and for a setting a @@ -886,8 +910,10 @@ fn acmeCtl(p: *Pardes, req: Req, serial: u32, line: []const u8, apply: bool) Acm const File = tree.PaneFile; // A word written to one of the pane's own files, as acme's is to its. const Into = struct { file: File, data: []const u8 }; + // `name` takes all after its one blank, as the name file takes what is + // written: a blank at its start is refused there, not trimmed here. const into: ?Into = if (std.mem.eql(u8, word, "name") and rest.len > 0) - .{ .file = .name, .data = rest } + .{ .file = .name, .data = line[word.len + 1 ..] } else if (std.mem.eql(u8, line, "clean")) .{ .file = .dirty, .data = "0" } else if (std.mem.eql(u8, line, "dirty")) @@ -983,6 +1009,7 @@ fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; f.disk_newer = null; f.disk_gone = false; if (discarded) { @@ -1007,6 +1034,21 @@ const root_status = @intFromEnum(tree.TopFile.status); const root_look = @intFromEnum(tree.TopFile.look); const root_exec = @intFromEnum(tree.TopFile.exec); +test "a control character in a write to any ctl refuses the whole write, and a quoted line shows a tab as a blank" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const col = layout.columnSerial(p, 0); + for ([_]u64{ @intFromEnum(tree.TopFile.ctl), Node.of(serialOf(p), .ctl), Node.ofCol(col, .ctl) }) |node| { + const r = wr(p, node, "Wrap off\nbo\x01gus\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings(e_control, r.reply.ename); + } + const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n"); + try testing.expectEqual(E.INVAL, quoted.errno()); + try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "bogus word") != null); + try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null); +} + test "pane ctl read is acme's fields -- index's five, width in cells, font, tab width, undo, redo -- then whether it is current" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); @@ -2403,11 +2445,33 @@ test "a look that misses file:/re/ names the file, and is logged as its pane's" try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} look: {s}/f.txt: no match for regexp", .{ f_serial, dir }))); } +test "the ctl word name refuses a leading blank, as the name file does" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const r = wr(p, Node.of(serial, .ctl), "name /tmp/lead.txt\n"); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectEqualStrings("bad character in file name: a blank at its start", r.reply.ename); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "name /tmp/ok.txt\n").reply.status); + try testing.expectEqualStrings("/tmp/ok.txt", pane_files.nameOf(p, p.panes[0].?)); +} + +test "a builtin that takes no argument, written with one to an exec, is refused wrong #args, as at a ctl" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + for ([_]u64{ @intFromEnum(tree.TopFile.exec), Node.of(serial, .exec) }) |node| for ([_][]const u8{ "Config extra\n", "DumpConfig extra\n" }) |line| { + const r = wr(p, node, line); + try testing.expectEqual(E.INVAL, r.errno()); + try testing.expectStringStartsWith(r.reply.ename, "wrong #args in control message"); + }; +} + test "acme's words run as pardes's (Put is Save, Delete a Del that does not ask) or are refused, never run as commands" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); const serial = serialOf(p); - for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n" }) |line| { + for ([_][]const u8{ "Snarf\n", "Putall\n", "Zerox\n", "Get\n", "Tab\n", "Indent\n", "Local\n", "Incl\n", "Abort\n" }) |line| { for ([_]u64{ Node.of(serial, .ctl), Node.of(serial, .exec) }) |node| { const r = wr(p, node, line); try testing.expectEqual(E.INVAL, r.errno()); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index fe7af77e..c6505f52 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -153,10 +153,17 @@ fn bootFirst(p: *Pardes) void { /// Records `<kind> <serial> <name>`. pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void { + // Nothing about a pane comes before its `new`: one not announced yet + // is announced first, and a rename of it is no record, as its `new` + // carries the name it has by then. + if (pane.fs.unannounced and kind != .new) { + if (kind == .rename) return; + announce(p); + } 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); + const name = shown(pane_files.nameOf(p, pane), &name_buf); pushLog(p, std.fmt.bufPrint(&buf, "{s} {d} {s}\n", .{ @tagName(kind), pane.serial, name }) catch return); } @@ -1011,7 +1018,7 @@ test "the log records whether or not anyone reads, and an open that follows wait // Nobody holds /log open, and the pane is recorded anyway. const early = try th.newPane(p); - const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p.panes[p.paneBySerial(early).?].?) }); + const early_line = try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ early, pane_files.nameOf(p, p.panes[p.paneBySerial(early).?].?) }); const early_owned = try gpa.dupe(u8, early_line); defer gpa.free(early_owned); @@ -1048,7 +1055,7 @@ test "the log records whether or not anyone reads, and an open that follows wait const serial = try th.newPane(p); const id = p.paneBySerial(serial).?; try testing.expectEqualStrings( - try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p.panes[id].?) }), + try std.fmt.bufPrint(&expected, "new {d} {s}\n", .{ serial, pane_files.nameOf(p, p.panes[id].?) }), rdf.next(p, log, fh, frozen).bytes, ); try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/logged.txt\n").reply.status); @@ -1176,6 +1183,45 @@ test "a Dump written and a Restore made are in the log, with their files" { try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restoredcol {d} {d}\n", .{ pardes.layout.columnSerial(p, 0), pardes.layout.columnSerial(restored, 0) }))); } +test "a Restore gives columns fresh serials, past every serial the old session handed out" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const first = pardes.layout.columnSerial(p, 0); + // Serials spent on columns since deleted. + p.next_column_serial += 5; + const spent = p.next_column_serial; + try dump.dumpState(p); + const restored = try dump.restore(p, p.dump_out.?, "/tmp/pardes.dump.zon"); + defer restored.deinit(); + for (0..restored.ncol) |c| try testing.expect(pardes.layout.columnSerial(restored, c) >= spent); + try testing.expect(pardes.layout.columnSerial(restored, 0) != first); +} + +test "a pane named before it is announced has its new under that name, and no rename before it" { + const p = try withFile(testing.allocator, "one\n"); + defer p.deinit(); + const pane = p.panes[0].?; + // As a pane Config makes and names in one update. + pane.fs.unannounced = true; + try pane_files.nameBuffer(p, 0, "/tmp/named-first.txt", false); + announce(p); + const log = try freezeLog(p); + defer p.gpa.free(log.bytes); + var want: [64]u8 = undefined; + try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "new {d} /tmp/named-first.txt\n", .{pane.serial}))); + try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{pane.serial})) == null); + // A shell given no directory is named by the session's from the first, + // and saying it is there is no rename. + const free = p.freeSlot().?; + const shell = try p.newShell(free, ""); + try testing.expectEqualStrings(p.sessionDir(), pane_files.nameOf(p, shell)); + announce(p); + p.setCwd(free, p.sessionDir()); + const after = try freezeLog(p); + defer p.gpa.free(after.bytes); + try testing.expect(std.mem.indexOf(u8, after.bytes, try std.fmt.bufPrint(&want, "rename {d} ", .{shell.serial})) == null); +} + test "opens of the log share the open records, and a closed one frees its record" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 3fad1fae..6a0fbba5 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -77,6 +77,10 @@ pub const State = struct { /// whole of it is dropped and draws no empty row. tag_rewrite: bool = false, tag_held_newline: bool = false, + /// A truncation of `tag` not yet done: the write after it does it, once + /// the write is known to fit, so a refused `>` leaves the tag as it was; + /// a read or a close with no write does it then. + tag_trunc_pending: bool = false, /// The pty's size as pty/ctl's winsize last set it, until the pane's own /// grid resizes and gives the pty its size again. winsize: ?[2]u16 = null, @@ -130,12 +134,14 @@ pub fn dirtyOf(pane: *const Pane) bool { } /// The pane's name: its file, its document, or a terminal's directory. -pub fn nameOf(pane: *const Pane) []const u8 { +pub fn nameOf(p: *Pardes, pane: *const Pane) []const u8 { if (pane.file) |*f| return f.path; if (pane.pdfPath()) |path| return path; if (pane.image) |state| return state.path; const cwd = pane.cwdSlice(); - return if (cwd.len > 0) cwd else "/"; + // A shell given no directory was started in the session's, and is + // named by it until it says where it is (a `new` then, not a rename). + return if (cwd.len > 0) cwd else p.sessionDir(); } pub fn kindOf(pane: *const Pane) []const u8 { @@ -149,12 +155,62 @@ pub fn clip(n: usize) u32 { return std.math.cast(u32, n) orelse std.math.maxInt(u32); } +/// The file's line index when it describes `text` (built by a render or a +/// look), else null: with it a row is a binary search, not a count of every +/// newline from the top, which a bulk write paid on each flush, twice over. +fn lineIndex(pane: *const Pane, text: []const u8) ?[]const usize { + const f = if (pane.file) |*file| file else return null; + if (f.line_starts.len == 0 or f.content.ptr != text.ptr or f.content.len != text.len) return null; + return f.line_starts; +} + +/// The line `off` is on: the last start at or before it. +fn rowAt(starts: []const usize, off: usize) usize { + const after = std.sort.upperBound(usize, starts, off, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + return after -| 1; +} + +fn lineEnd(starts: []const usize, text: []const u8, row: usize) usize { + return if (row + 1 < starts.len) starts[row + 1] - 1 else text.len; +} + +/// `modal.runeOffsetAt`, through the line index when there is one. +fn runeOffsetAt(pane: *const Pane, text: []const u8, c: modal.Cursor) usize { + const starts = lineIndex(pane, text) orelse return modal.runeOffsetAt(text, c); + const row = @min(c.row, starts.len - 1); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return s + modal.runeStart(text[s..e], @min(c.col, e - s)); +} + +/// `modal.runePositionAt`, through the line index when there is one. +fn runePositionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.runePositionAt(text, off); + const bounded = modal.runeStart(text, off); + const row = rowAt(starts, bounded); + return .{ .row = row, .col = bounded - starts[row] }; +} + +/// `modal.positionAt`, through the line index when there is one. +fn positionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.positionAt(text, off); + const bounded = @min(off, text.len); + const row = rowAt(starts, bounded); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return .{ .row = row, .col = modal.graphemeStart(text[s..e], @min(bounded - s, e - s)) }; +} + pub fn dotOf(pane: *Pane) State.Range { const text = bodyOf(pane); // In runes, as every address is (modal.runeStart). - const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); + const head = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); + const anchor = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); var hi = @max(head, anchor); if (hi < text.len) hi = modal.nextRune(text, hi); return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; @@ -164,9 +220,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void { const text = bodyOf(pane); const q0 = @min(@as(usize, r.q0), text.len); const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.runePositionAt(text, q0); + const a = runePositionAt(pane, text, q0); pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0); + const h = runePositionAt(pane, text, if (q1 > q0) modal.prevRune(text, q1) else q0); pane.body.cur_row = @intCast(h.row); pane.body.cur_col = @intCast(h.col); pane.body.cur_pinned = true; @@ -177,7 +233,7 @@ pub fn setDot(pane: *Pane, r: State.Range) void { pub fn showOffset(pane: *Pane, off: usize) void { const text = bodyOf(pane); - const c = modal.positionAt(text, @min(off, text.len)); + const c = positionAt(pane, text, @min(off, text.len)); pane.body.cur_row = @intCast(c.row); pane.body.cur_col = @intCast(c.col); pane.body.cur_pinned = true; @@ -229,10 +285,28 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const const join = pane.fs.joined == f.revision; pane.fs.joined = null; if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, new); + panes.File.setContentSpan(p, f, new, .{ .head = lo, .tail = f.content.len - hi }); return take; } +test "rows through the line index are the ones counting newlines gives, at every offset" { + const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail"); + defer p.deinit(); + const pane = p.panes[0].?; + const f = &pane.file.?; + _ = try panes.File.lineIndex(p.gpa, f); + const text = bodyOf(pane); + try testing.expect(lineIndex(pane, text) != null); + for (0..text.len + 2) |off| { + try testing.expectEqual(modal.positionAt(text, off), positionAt(pane, text, off)); + try testing.expectEqual(modal.runePositionAt(text, off), runePositionAt(pane, text, off)); + } + for (0..8) |row| for (0..16) |col| { + const c: modal.Cursor = .{ .row = row, .col = col }; + try testing.expectEqual(modal.runeOffsetAt(text, c), runeOffsetAt(pane, text, c)); + }; +} + test "a terminal's body stats as long as it reads" { const gpa = testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); @@ -261,7 +335,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { .body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, - .name => nameOf(pane).len + 1, + .name => nameOf(p, pane).len + 1, .ctl => ctl: { var buf: [ctl.pane_text_cap]u8 = undefined; break :ctl ctl.paneText(p, pane, &buf).len; @@ -308,7 +382,7 @@ pub fn indexLen(p: *Pardes) u64 { // unreachable: a u32 serial is at most 10 digits n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len; var name_buf: [4 * 4096]u8 = undefined; - n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1; + n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(p, pane), &name_buf).len + 1; // unreachable: a column index is under 16 digits n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } @@ -322,12 +396,13 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply return switch (file) { .name => name: { const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, nameOf(pane)) catch {}; + out.appendSlice(p.gpa, nameOf(p, pane)) catch {}; out.append(p.gpa, '\n') catch {}; break :name tree.stagedReply(p, req); }, .body => readBody(p, req, id, pane), .tag => tag: { + applyTagTruncation(p, pane); const out = p.fs.stage(p.gpa); out.appendSlice(p.gpa, tagOf(p, pane)) catch {}; break :tag tree.stagedReply(p, req); @@ -448,7 +523,7 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply return switch (file) { .name => writeName(p, req, id, pane), .body => writeBody(p, req, id, pane), - .tag => writeTag(req, pane), + .tag => writeTag(p, req, pane), .ctl => ctl.writePane(p, req, pane), .addr, .dot, .limit => writeRange(req, pane, file), .data, .xdata => writeData(p, req, pane), @@ -562,16 +637,28 @@ pub fn tagFault(data: []const u8) ?[]const u8 { return null; } -fn writeTag(req: Req, pane: *Pane) Reply { +/// A tag write's limit, the same for a pane's, a column's and the +/// workspace's: whole or not at all. +pub const e_tag_over = std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}); + +fn writeTag(p: *Pardes, 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); + const pf = &pane.fs; + // A refused write after a truncation changes nothing: the truncation + // it would have come with is dropped too. + if (tagFault(req.data)) |why| { + pf.tag_trunc_pending = false; + 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; - const had = tagline.curTail(pane).len; - const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline); - // Whole or not at all: a write that would pass the limit changes nothing. - if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail})); + const had = if (pf.tag_trunc_pending) 0 else tagline.curTail(pane).len; + const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline and !pf.tag_trunc_pending); + if (req.data.len > room) { + pf.tag_trunc_pending = false; + return tree.failText(req.tag, E.NOSPC, e_tag_over); + } + applyTagTruncation(p, pane); const take = wholeUtf8(req.data); // A truncating write drops ONE trailing newline, its whole text's: a // newline held from a write before goes in once more text follows it. @@ -588,6 +675,26 @@ fn writeTag(req: Req, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } +/// A pending truncation of the tag, done: its text cleared as `cleartag` +/// clears it, the prefix left. +pub fn applyTagTruncation(p: *Pardes, pane: *Pane) void { + const pf = &pane.fs; + if (!pf.tag_trunc_pending) return; + pf.tag_trunc_pending = false; + const empty = p.gpa.alloc(u8, 0) catch return; + // An edit like a keyboard one: `u` in the tag brings it back. + pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null); + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = empty; + // what is left is the prefix; the cursor goes after it + pane.tag.cur_row = 0; + pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0; + pane.tag.vsel.active = false; + pane.tag.nsel = 0; + pf.tag_rewrite = true; + pf.tag_held_newline = false; +} + /// 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)"; @@ -738,6 +845,9 @@ fn nameFault(name: []const u8) ?[]const u8 { } if (name[0] == ' ') return e_name_char ++ ": a blank at its start"; if (name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end"; + // No file system takes a longer one (NAME_MAX): a Save would only fail. + var parts = std.mem.splitScalar(u8, name, '/'); + while (parts.next()) |part| if (part.len > 255) return "invalid file name: a component over 255 bytes"; return null; } @@ -757,6 +867,11 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { error.OutOfMemory => E.NOMEM, else => E.INVAL, }); + // A name into a directory not there yet asks for it, as Config's pane + // does: its Save makes the directories (`pardes new/dir/f` forwarded). + if (comptime pardes.hosted) if (fs.localPath(std.fs.path.dirname(full) orelse "/")) |local| { + if (!exec.isDirectory(local)) fileOf(pane).?.make_dirs = true; + }; return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -855,20 +970,8 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) }); pf.addr = .{ .q0 = q0, .q1 = q0 }; }, - .tag => { - const empty = p.gpa.alloc(u8, 0) catch return .err; - // An edit like a keyboard one: `u` in the tag brings it back. - pane.tag.remember(p.gpa, if (pane.tag.own) |own| .{ .text = own } else null); - if (pane.tag.own) |own| p.gpa.free(own); - pane.tag.own = empty; - // what is left is the prefix; the cursor goes after it - pane.tag.cur_row = 0; - pane.tag.cur_col = if (tagline.pathPrefix(p.scratch.allocator(), pane)) |prefix| @intCast(prefix.len) else |_| 0; - pane.tag.vsel.active = false; - pane.tag.nsel = 0; - pf.tag_rewrite = true; - pf.tag_held_newline = false; - }, + // Done with the write after it, which may yet be refused. + .tag => pf.tag_trunc_pending = true, // A shell's `>` truncates before it writes: the address written // is evaluated from where the last one left off, as with `>>`. // `0` (or `,`) is how to start over. See State.addr. @@ -923,7 +1026,7 @@ test "index lists serial kind dirty flag and name and stat reports its length" { try testing.expectEqual(@as(u64, both.bytes.len), indexLen(p)); var expected: [4200]u8 = undefined; const made = p.panes[p.paneBySerial(serial).?].?; - try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(made), columnOf(p, p.paneBySerial(serial).?) }))); + try testing.expect(std.mem.endsWith(u8, both.bytes, try std.fmt.bufPrint(&expected, "\n{d} text 0 {s} {d}\n", .{ serial, nameOf(p, made), columnOf(p, p.paneBySerial(serial).?) }))); } test "a terminal is listed as term with its directory as name" { @@ -1260,13 +1363,46 @@ test "a write of two lines to name is refused EINVAL, on a held open or not" { const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/a\n/tmp/b\n" }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); - try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); // Line by line, as bash writes it: the second line is refused. const h2 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-a\n" }).reply.status); try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-b\n" }).errno()); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h2, .opened = true }); - try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); +} + +test "a name with a component over 255 bytes is refused, and a long path's failed Save keeps its reason" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const refused = wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 256 ++ "/f.txt\n"); + try testing.expectEqual(E.INVAL, refused.errno()); + try testing.expectEqualStrings("invalid file name: a component over 255 bytes", refused.reply.ename); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/" ++ "c" ** 255 ++ "\n").reply.status); + // A Save of a 3000-byte path the host refuses: the waiting write's + // reason is at the end, the path giving up its middle. + const long = "/nonexistent-pardes-root/" ++ ("d" ** 200 ++ "/") ** 15 ++ "f.txt"; + const id = p.paneBySerial(serial).?; + try nameBuffer(p, id, long, false); + p.fs.late_failure_len = 0; + p.saveFailed(@intCast(id), long, error.AccessDenied); + const late = p.fs.late_failure[0..p.fs.late_failure_len]; + try testing.expect(std.mem.startsWith(u8, late, "Save /nonexistent-pardes-root/")); + try testing.expect(std.mem.endsWith(u8, late, "/f.txt: no such directory")); +} + +test "a name into a directory not there has Save make it; one into a directory there does not" { + if (comptime !pardes.hosted) return error.SkipZigTest; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const f = fileOf(p.panes[p.paneBySerial(serial).?].?).?; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-mk/sub/f.txt\n").reply.status); + try testing.expect(f.make_dirs); + f.make_dirs = false; + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/f.txt\n").reply.status); + try testing.expect(!f.make_dirs); } test "a name cut across writes is one name, applied once at its newline or its close" { @@ -1280,16 +1416,16 @@ test "a name cut across writes is one name, applied once at its newline or its c p.fs.write_room = "/tmp/pardes-na".len; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/pardes-na" }); p.fs.write_room = 0; - try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/test.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "me.txt\n" }); - try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-name.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); // No newline, the whole write: applied at once, on its own open. const h3 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; _ = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h3, .data = "/tmp/pardes-closed.txt" }); - try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h3, .opened = true }); - try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + try testing.expectEqualStrings("/tmp/pardes-closed.txt", nameOf(p, p.panes[p.paneBySerial(serial).?].?)); } test "a replace through data, truncate then write, is one undo step" { @@ -1329,12 +1465,15 @@ test "truncating the tag clears its editable tail" { try testing.expect(tagline.curTail(pane).len > 0); const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); try testing.expectEqual(Status.ok, cleared.reply.status); + // Done at the first read or write after it (or the close). + _ = rd(p, tag, 0, 4096); try testing.expectEqualStrings("", pane.tag.own.?); try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null); // The tag keeps its undo: undoing in it brings back what was cleared. pardes.edit.doUndo(p, &pane.tag); try testing.expect(std.mem.endsWith(u8, tagline.curTail(pane), " Mine")); _ = call(p, .{ .tag = 2, .op = .setattr, .node = tag, .truncate = true }); + _ = rd(p, tag, 0, 4096); try testing.expectEqualStrings("", pane.tag.own.?); // Written back without a leading space, the text still stands apart @@ -1373,7 +1512,7 @@ test "name reads the file name and writing it promotes a scratch without touchin const name = Node.of(serial, .name); const body = Node.of(serial, .body); var scratch_name: [4200]u8 = undefined; - const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(pane)}); + const scratch_line = try std.fmt.bufPrint(&scratch_name, "{s}\n", .{nameOf(p, pane)}); try testing.expectEqualStrings(scratch_line, rd(p, name, 0, 4096).bytes); try testing.expectEqual(@as(u64, scratch_line.len), call(p, .{ .tag = 1, .op = .getattr, .node = name }).reply.attr.size); _ = wr(p, body, "scratch "); diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 5f3ffc9c..17941e10 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -605,7 +605,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .tag, .exec => 0o666, }, .size = switch (c.file) { - .tag => cols.headerText(p, c.serial).?.len + 1, + .tag => cols.headerText(p, c.serial).?.len, .exec => ctl.resultsLen(p), else => 0, }, @@ -647,7 +647,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { // and /pane/new none until an open has a pane to name. .root, .pane, .col, .screen, .new => 0, .layout => cols.layoutLen(p), - .tag => cols.headerText(p, null).?.len + 1, + .tag => cols.headerText(p, null).?.len, .index => pane.indexLen(p), .README => fs.help.len, .status => ctl.statusLen(p), @@ -935,6 +935,7 @@ fn releaseHandle(p: *Pardes, req: Req) void { // A truncating write to `tag` is over when its open goes (pane.zig // writeTag): a newline it held back is dropped. if (t == .pane and t.pane.file == .tag) if (p.paneBySerial(t.pane.serial)) |id| { + pane.applyTagTruncation(p, p.panes[id].?); p.panes[id].?.fs.tag_rewrite = false; p.panes[id].?.fs.tag_held_newline = false; }; @@ -1052,7 +1053,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { // 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); + const name = events.shown(pane.nameOf(p, 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); @@ -1146,12 +1147,21 @@ fn holdsLines(p: *Pardes, req: Req) bool { /// A line held back longer than this is refused rather than kept growing. const pending_cap = 1 << 20; +/// The ctl files, whose lines are commands as look's and exec's are. +fn ctlFile(target: Target) bool { + return switch (target) { + .top => |f| f == .ctl, + .col => |c| c.file == .ctl, + .pane => |t| t.file == .ctl, + }; +} + fn write(p: *Pardes, req: Req, target: Target) Reply { + // A command line holds no control character but a tab: the whole write + // is refused at once, not held to fail unseen at the close. + if (resultsFile(target) or ctlFile(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); const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target); 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') 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); var end = ctl.completeEnd(p, o.pending.items); // A tail with no newline is a whole line when the write is the whole of |
