summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/addr.zig13
-rw-r--r--src/ninep/cols.zig128
-rw-r--r--src/ninep/ctl.zig72
-rw-r--r--src/ninep/events.zig52
-rw-r--r--src/ninep/pane.zig221
-rw-r--r--src/ninep/tree.zig22
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