summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/cols.zig11
-rw-r--r--src/ninep/ctl.zig269
-rw-r--r--src/ninep/events.zig76
-rw-r--r--src/ninep/pane.zig33
-rw-r--r--src/ninep/pty.zig8
-rw-r--r--src/ninep/tree.zig4
6 files changed, 314 insertions, 87 deletions
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index aae6d364..6b59f9e5 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -401,19 +401,18 @@ test "tagexec runs a word as a click in the workspace tag; exec files read back
try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status);
try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status);
try testing.expectEqual(@as(usize, 2), p.ncol);
- // What it says is the session's (Newcol announcing itself); a pane's
- // word such as Msg is refused there.
- try testing.expect(th.logHas(p, "msg - Newcol\n"));
+ // Its announcement is the message row's, never logged; a pane's word
+ // such as Msg is refused there.
+ try testing.expect(!th.logHas(p, "msg - Newcol\n"));
try testing.expectEqual(E.INVAL, th.wr(p, tagexec, "Msg from the top\n").errno());
- // A column's exec makes a pane there, reads it back, and its Msg is
- // the session's too.
+ // A column's exec makes a pane there and reads it back.
const right = layout.columnSerial(p, 1);
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status);
const made = p.panes[p.col_panes[1][0]].?.serial;
var want: [16]u8 = undefined;
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes);
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes);
- try testing.expect(th.logHas(p, "msg - New\n"));
+ try testing.expect(!th.logHas(p, "msg - New\n")); // announced on the message row only
}
test "a word run from the workspace's or a column's tag runs in the session's directory, a pane's in its own" {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 477a91fe..c2a78fb8 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -388,11 +388,11 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const b = std.meta.stringToEnum(Builtin, word) orelse {
// A setting this build's frontend cannot show (Lift, GripWidth on a
// terminal) is known, and says so rather than sound misspelt.
- if (config.Runtime.find(word) != null) {
+ if (builtins.absentWhy(word) orelse if (config.Runtime.find(word) != null) @as([]const u8, "GUI-only") else null) |absent| {
var why: [64]u8 = undefined;
// EINVAL, a request this build cannot take, in words 9ns reads
// as that: `invalid`.
- return refuse(p, req, std.fmt.bufPrint(&why, "{s} is GUI-only, invalid here", .{word}) catch "GUI-only, invalid here", line);
+ return refuse(p, req, std.fmt.bufPrint(&why, "{s} is {s}, invalid here", .{ word, absent }) catch "GUI-only, invalid here", line);
}
return refuse(p, req, "unknown control message", line);
};
@@ -408,13 +408,15 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
const setting = config.Runtime.find(word) orelse return null;
const takes = switch (setting.action) {
- .theme => for (pardes.themes) |t| {
- if (std.mem.eql(u8, t.name, arg)) break true;
- } else {
- // `refuse` quotes the line after it, in what is left of 128.
- var near: [40]u8 = undefined;
- var why: [104]u8 = undefined;
- return refuse(p, req, std.fmt.bufPrint(&why, "bad value in control message; like it: {s}; Themes lists all", .{pardes.colors.themesNear(&near, arg)}) catch "bad value in control message", line);
+ .theme => if (pardes.colors.themeIndex(arg) != null) true else {
+ // `refuse` quotes the line after it, all in the 128 bytes an
+ // Rerror carries: the names get what is left.
+ const head = "bad value in control message; like it: ";
+ const tail = "; Themes lists all";
+ var near: [128]u8 = undefined;
+ const room = @import("cloud9").fs.errmax -| (head.len + tail.len + line.len + 3);
+ var why: [256]u8 = undefined;
+ return refuse(p, req, std.fmt.bufPrint(&why, head ++ "{s}" ++ tail, .{pardes.colors.themesNear(near[0..@min(room, near.len)], arg)}) catch "bad value in control message", line);
},
.font => config.Runtime.FontSpec.parse(arg) != null,
else => probe: {
@@ -864,6 +866,8 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
const id = p.paneBySerial(pane.serial).?;
// Kept in +Unsaved, as Exit's refusal is; the notice is short. The
// write's err record logs it, not a msg too.
+ // And logged `unsaved <serial> <name>`, as the other refusals are.
+ tree.events.noteLog(p, .unsaved, pane);
const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null;
if (listed) |content| panes.Output.openUnsaved(p, id, content);
p.fs.unlogged = true;
@@ -1465,6 +1469,8 @@ test "get reloads the pane from disk and reports a missing file without losing d
const asked = wr(p, ctl_node, "get\n");
try testing.expectEqual(E.IO, asked.errno());
try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)"));
+ var unsaved: [4200]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&unsaved, "unsaved {d} {s}/missing.txt\n", .{ serial, directory })));
try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno());
try testing.expect(p.paneBySerial(serial) != null);
try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content);
@@ -1610,12 +1616,34 @@ test "Joincol with no column to the right and Theme with no such theme say so" {
const themed = wr(p, root_ctl, "Theme drak\n");
try testing.expectEqual(E.INVAL, themed.errno());
try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: ");
- try testing.expect(std.mem.indexOf(u8, themed.reply.ename, " dark") != null or std.mem.indexOf(u8, themed.reply.ename, ": dark") != null);
+ try testing.expect(std.mem.indexOf(u8, themed.reply.ename, ": dr") != null);
try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\""));
// A click on the word says it on the message row.
_ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n");
const pane = p.panes[p.active].?;
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null);
+ // Within the 128 bytes an Rerror carries, the quoted line kept.
+ try testing.expect(themed.reply.ename.len <= 128);
+ // A name in any case is the theme.
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Theme DRACULA\n").reply.status);
+ try testing.expectEqualStrings("dracula", pardes.themes[p.settings.theme].name);
+ // A misspelling far from any first letter's list still finds its like.
+ var near: [170]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, pardes.colors.themesNear(&near, "9dracula"), "dracula") != null);
+ // Every theme sharing the first letter, when they fit.
+ var tested = false;
+ for ("abcdefghijklmnopqrstuvwxyz") |letter| {
+ var count: usize = 0;
+ var len: usize = 0;
+ for (pardes.themes) |t| if (t.name[0] == letter) {
+ count += 1;
+ len += t.name.len + 1;
+ };
+ if (count < 2 or len > near.len) continue;
+ try testing.expectEqual(count, std.mem.count(u8, pardes.colors.themesNear(&near, &.{letter}), " ") + 1);
+ tested = true;
+ }
+ try testing.expect(tested);
}
test "a look after Newcol fills the empty column the keyboard is on" {
@@ -1663,6 +1691,10 @@ test "a Restore of a file that is no dump fails the write before any warning, an
p.fs.late_failure_len = 0;
p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound);
try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: no such directory", p.fs.late_failure[0..p.fs.late_failure_len]);
+ // A DumpDir that is there but no directory says that.
+ p.fs.late_failure_len = 0;
+ p.dumpFailed("/dev/null/pardes.dump.zon", error.NotDir);
+ try testing.expectEqualStrings("Dump /dev/null/pardes.dump.zon: /dev/null is not a directory", p.fs.late_failure[0..p.fs.late_failure_len]);
}
test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" {
@@ -1672,6 +1704,14 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
if (!builtins.capabilities.lift) {
try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename);
try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename);
+ // A builtin the GUI alone has, written to a ctl or an exec, never
+ // runs as a shell command.
+ if (comptime !pardes.font_picker) {
+ try testing.expectEqualStrings("Fonts is GUI-only, invalid here \"Fonts\"", wr(p, root_ctl, "Fonts\n").reply.ename);
+ const ran = wr(p, root_exec, "Fonts\n");
+ try testing.expectEqual(E.IO, ran.errno());
+ try testing.expectEqualStrings("Fonts is GUI-only, invalid here", ran.reply.ename);
+ }
}
try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename);
var buf: [900]u8 = undefined;
@@ -1860,6 +1900,18 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir
try testing.expect(std.mem.indexOf(u8, found.file.?.content, "other.txt") != null);
try testing.expectEqual(grepped.serial, found.serial);
try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") == null);
+ // The same Find again reads back the +Search, not the pane it ran in.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .exec), "Find other\n").reply.status);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes);
+ // A Grep run from the +Search itself fills that one, never a second.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(found.serial, .exec), "Grep bravo\n").reply.status);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&line, "{d}\n", .{found.serial}), rd(p, root_exec, 0, 64).bytes);
+ var searches: usize = 0;
+ for (p.panes) |slot| if (slot) |q| if (q.file) |f| if (f.output != null) {
+ searches += @intFromBool(std.mem.endsWith(u8, f.path, "/+Search"));
+ };
+ try testing.expectEqual(@as(usize, 1), searches);
+ try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") != null);
// A second pane in a directory that is not there.
const other = try th.newPane(p);
_ = wr(p, Node.of(other, .name), "/tmp/pardes-no-such-dir-zz/b.txt\n");
@@ -1874,6 +1926,8 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir
var after: usize = 0;
for (p.panes) |slot| after += @intFromBool(slot != null);
try testing.expectEqual(count, after);
+ // The +Search the miss would have filled is emptied, not left stale.
+ try testing.expectEqualStrings("", found.file.?.content);
}
test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" {
@@ -1982,6 +2036,49 @@ test "pty/ctl exec in a directory that is gone fails ENOENT; a shell that cannot
try testing.expectEqualStrings("shell: access denied", p.fs.late_failure[0..p.fs.late_failure_len]);
}
+test "a script whose interpreter is not there: Tty refuses it up front, only an err logged, and pty/ctl exec keeps the running shell" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const Starting = struct {
+ fn spawn(_: ?*anyopaque, _: u8, _: []const u8) void {}
+ };
+ p.host = .{ .vtable = &.{ .spawn = Starting.spawn } };
+ defer p.host = .{};
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad", .data = "#!/no/such/interp\n", .flags = .{ .permissions = .executable_file } });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ const serial = serialOf(p);
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ const refused = wr(p, Node.of(serial, .ctl), try std.fmt.bufPrint(&line, "Tty {s}/bad\n", .{dir}));
+ try testing.expectEqual(E.NOENT, refused.errno());
+ try testing.expectEqualStrings("Tty: interpreter /no/such/interp not found", refused.reply.ename);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // The log gained the err and nothing else: no new, msg or del.
+ try testing.expect(th.logHas(p, "Tty: interpreter /no/such/interp not found\n"));
+ try testing.expect(!th.logHas(p, "\nmsg "));
+ try testing.expect(!th.logHas(p, "\ndel "));
+ // The shell to start again is that script: refused before the running
+ // one goes.
+ const id = p.paneBySerial(serial).?;
+ p.panes[id].?.shell = try p.gpa.dupe(u8, try std.fmt.bufPrint(&line, "{s}/bad", .{dir}));
+ p.setCwd(id, dir);
+ const exec_refused = wr(p, Node.of(serial, .pty_ctl), "exec\n");
+ try testing.expectEqual(E.NOENT, exec_refused.errno());
+ try testing.expectEqualStrings("exec: interpreter /no/such/interp not found", exec_refused.reply.ename);
+ // One the host found could not start (the script changed after): said
+ // with the interpreter's name, and the running shell is not gone.
+ p.fs.late_failure_len = 0;
+ p.restartFailed(@intCast(id), error.InterpreterNotFound);
+ try testing.expectEqualStrings("shell: interpreter /no/such/interp not found", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expect(!p.panes[id].?.shell_failed);
+}
+
test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -2022,6 +2119,26 @@ test "/index shows a name as the log does: a newline in it is \\n, controls spac
try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, "/tmp/back\\\\nslash") != null);
}
+test "a look at a ./ or ../ name that is not there names the file, ENOENT; a plain one is looked for as text" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(testing.io, "many");
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, Node.of(serialOf(p), .name), try std.fmt.bufPrint(&line, "{s}/many/a.txt\n", .{dir}));
+ for ([_][]const u8{ "./zz.txt", "../zz.txt" }) |name| {
+ const r = wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "{s}\n", .{name}));
+ try testing.expectEqual(E.NOENT, r.errno());
+ var want: [64]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "look: {s}: no such file", .{name}), r.reply.ename);
+ }
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .look), "zz.txt\n").reply.status);
+ try testing.expect(th.logHas(p, "look: no match for \"zz.txt\""));
+}
+
test "a served-tree pane spoils no Grep, and Look . from a gone directory is ENOENT, not /virtual/" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -2141,8 +2258,8 @@ test "a closed file is found again: Recent and /recent list it closed, a look at
const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
const at = std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}:3:", .{a})).?;
const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
- try testing.expect(std.mem.endsWith(u8, row, " closed"));
- try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " closed".len]})).reply.status);
+ try testing.expect(std.mem.endsWith(u8, row, " (closed)"));
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), try std.fmt.bufPrint(&line, "{s}\n", .{row[0 .. row.len - " (closed)".len]})).reply.status);
p.sync();
const back = p.panes[p.active].?;
try testing.expectEqualStrings(a, back.file.?.path);
@@ -2163,6 +2280,134 @@ test "a closed file is found again: Recent and /recent list it closed, a look at
try testing.expectEqualStrings(a, p.panes[p.active].?.file.?.path);
}
+test "Back and Forward over a closed file's entries: one reopen is that file's pane in every entry, so the next step moves" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var long: [400]u8 = undefined;
+ var n: usize = 0;
+ for (0..100) |_| {
+ @memcpy(long[n..][0..4], "row\n");
+ n += 4;
+ }
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = long[0..n] });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "b.txt", .data = "bee\n" });
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "sea\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/b.txt\n", .{dir}));
+ p.sync();
+ const b_serial = p.panes[p.active].?.serial;
+ // Two entries for a.txt, far apart in it.
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:1\n", .{dir}));
+ p.sync();
+ const a = p.active;
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:90\n", .{dir}));
+ p.sync();
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt\n", .{dir}));
+ p.sync();
+ const c_serial = p.panes[p.active].?.serial;
+ _ = wr(p, Node.of(p.panes[a].?.serial, .ctl), "Del\n");
+ p.sync();
+ var closed: usize = 0;
+ for (p.jumps[0..p.njumps]) |j| closed += @intFromBool(j.serial == 0);
+ try testing.expectEqual(@as(usize, 2), closed);
+ // Back reopens it at line 90: both its entries are that pane now.
+ p.jumpBy(-1);
+ p.sync();
+ const re = p.panes[p.active].?;
+ try testing.expect(std.mem.endsWith(u8, re.file.?.path, "/a.txt"));
+ try testing.expectEqual(@as(i32, 89), re.body.cur_row);
+ for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0);
+ // Back again goes to its line 1, in the same pane, then to b.
+ p.jumpBy(-1);
+ p.sync();
+ try testing.expectEqual(re.serial, p.panes[p.active].?.serial);
+ try testing.expectEqual(@as(i32, 0), re.body.cur_row);
+ p.jumpBy(-1);
+ p.sync();
+ try testing.expectEqual(b_serial, p.panes[p.active].?.serial);
+ // And Forward walks it all again, to c.
+ p.jumpBy(1);
+ p.sync();
+ p.jumpBy(1);
+ p.sync();
+ try testing.expectEqual(@as(i32, 89), re.body.cur_row);
+ p.jumpBy(1);
+ p.sync();
+ try testing.expectEqual(c_serial, p.panes[p.active].?.serial);
+}
+
+test "Recent keeps only files on disk: not a name never saved, nor the served tree's" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "real.txt", .data = "real\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ const names = [_][]const u8{ try std.fmt.allocPrint(testing.allocator, "{s}/c", .{dir}), try testing.allocator.dupe(u8, "/virtual/zz"), try std.fmt.allocPrint(testing.allocator, "{s}/real.txt", .{dir}) };
+ defer for (names) |n| testing.allocator.free(n);
+ for (names) |name| {
+ const made = try th.newPane(p);
+ _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&line, "{s}\n", .{name}));
+ p.sync();
+ _ = wr(p, Node.of(made, .ctl), "Del\n");
+ _ = wr(p, Node.of(made, .ctl), "Del\n");
+ p.sync();
+ }
+ try testing.expect(p.recent.get(names[0]) == null);
+ try testing.expect(p.recent.get(names[1]) == null);
+ try testing.expect(p.recent.get(names[2]) != null);
+}
+
+test "/recent shows a name as /index does, a newline in it escaped" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.recent.opened(p.recentGpa(), "/tmp/a\nb\\c");
+ const text = rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes;
+ try testing.expect(std.mem.indexOf(u8, text, "closed /tmp/a\\nb\\\\c\n") != null);
+}
+
+test "a remembered place is clamped to the file as it is now, its selection's two ends kept where they fit" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\nthree\nfour\nfive\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ _ = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/c.txt:2\n", .{dir}));
+ p.sync();
+ const c = p.panes[p.active].?;
+ // A selection over lines 2 to 4, then closed.
+ @import("pane.zig").setDot(c, .{ .q0 = 4, .q1 = 19 });
+ try testing.expect(c.body.vsel.active);
+ _ = wr(p, Node.of(c.serial, .ctl), "Del\n");
+ p.sync();
+ // The file shrinks to two lines while it is closed.
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "c.txt", .data = "one\ntwo\n" });
+ _ = wr(p, root_exec, "Recent\n");
+ const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ const at = std.mem.indexOf(u8, rows.file.?.content, "c.txt:").?;
+ const row = rows.file.?.content[at .. std.mem.indexOfScalarPos(u8, rows.file.?.content, at, '\n').?];
+ // Both ends are in the row, a range.
+ try testing.expect(std.mem.indexOfScalar(u8, row, '-') != null);
+ var full: [4400]u8 = undefined;
+ const loc = try std.fmt.bufPrint(&full, "{s}/{s}\n", .{ dir, row[0 .. std.mem.indexOfScalar(u8, row, ' ').?] });
+ // A look at it opens the file, never a miss, clamped to its last line.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(rows.serial, .look), loc).reply.status);
+ p.sync();
+ const back = p.panes[p.active].?;
+ try testing.expect(std.mem.endsWith(u8, back.file.?.path, "/c.txt"));
+ try testing.expect(back.body.cur_row <= 1);
+ try testing.expect(back.body.vsel.active);
+}
+
test "size is monotonic: growing is never refused, and a size once taken is taken again" {
const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 });
defer p.deinit();
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 690efbe5..add0d750 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -141,10 +141,16 @@ pub fn announce(p: *Pardes) void {
pane.fs.unannounced = false;
noteLog(p, .new, pane);
// A file opened (a look, a Restore, a boot): first in Recent.
- if (pane.file) |f| if (f.output == null) p.recent.opened(p.recentGpa(), f.path);
+ if (pane.file) |f| if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path);
}
}
+/// The panes present at boot are the log's first records: something said
+/// before the first update ends (an init file's notice) waits for them.
+fn bootFirst(p: *Pardes) void {
+ if (newest(p) == null) announce(p);
+}
+
/// Records `<kind> <serial> <name>`.
pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
var buf: [4 * 4096 + 64]u8 = undefined;
@@ -161,10 +167,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined;
const kept = pardes.Messages.clip(said, pardes.Messages.LoggedMessage.cap);
const cut = if (kept.len < said.len) "…" else "";
- // A builtin's own word for what it did stands in for its Verbose
- // announcement just before (`Undo` then `Undo: nothing to undo`), so
- // that 1000 Undos with nothing left are one line, counted, as err's are.
- dropAnnouncement(p, serial, kept);
+ bootFirst(p);
pushCounting(p, (if (serial == 0)
std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut })
else
@@ -179,6 +182,7 @@ pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
/// `dump <path>` when a Dump is written, `restore <path>` first in a
/// Restore's replacement.
pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
+ bootFirst(p);
var buf: [pardes.memory.limits.host_path_cap + 16]u8 = undefined;
pushLog(p, std.fmt.bufPrint(&buf, "{s} {s}\n", .{ what, path }) catch return);
}
@@ -230,61 +234,6 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
pushCounting(p, record);
}
-/// Drops the newest record when it is the Verbose announcement (`msg
-/// <serial> <Word>`) of the builtin whose words `text` are (`Word: ...`):
-/// what the builtin said, or the err its failure logs, takes its place.
-/// Not one a follower has read.
-pub fn dropAnnouncement(p: *Pardes, serial: u32, text: []const u8) void {
- const last = newest(p) orelse return;
- if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
- const rest = last.text["msg ".len..];
- const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
- const word = rest[@min(sp + 1, rest.len)..];
- var who: [16]u8 = undefined;
- const mine = if (serial == 0) "-" else std.fmt.bufPrint(&who, "{d}", .{serial}) catch "";
- if (std.mem.eql(u8, rest[0..sp], mine) and word.len > 0 and std.mem.indexOfScalar(u8, word, ' ') == null and
- text.len > word.len and std.mem.startsWith(u8, text, word) and text[word.len] == ':')
- p.fs.log.buf.shrinkRetainingCapacity(last.at);
-}
-
-/// The sequence number of the newest log record when it is a msg that
-/// ends in `word` alone: not one counted (`(x2)`) over an earlier say.
-pub fn newestSaying(p: *Pardes, word: []const u8) ?u64 {
- const last = newest(p) orelse return null;
- if (!std.mem.startsWith(u8, last.text, "msg ") or !std.mem.endsWith(u8, last.text, word)) return null;
- if (last.text[last.text.len - word.len - 1] != ' ') return null;
- return last.seq;
-}
-
-/// Takes record `seq` back out of the log, unless a follower has read it.
-pub fn dropRecord(p: *Pardes, seq: u64) void {
- if (followerRead(p, seq)) return;
- const q = &p.fs.log;
- var at = q.head;
- var s = q.dropped;
- while (at + 4 <= q.buf.items.len) : (s += 1) {
- const size = 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
- if (s == seq) {
- const items = q.buf.items;
- std.mem.copyForwards(u8, items[at..], items[at + size ..]);
- q.buf.shrinkRetainingCapacity(items.len - size);
- return;
- }
- at += size;
- }
-}
-
-/// A builtin's failure: its Verbose announcement goes from the log, when
-/// `text` is that builtin's (it starts with its word) or `any`, as for a
-/// failure said while the builtin itself runs.
-pub fn dropFailedAnnouncement(p: *Pardes, text: []const u8, any: bool) void {
- const seq = p.fs.announced_seq orelse return;
- const word = p.fs.announced_word[0..p.fs.announced_word_len];
- if (!any and !(std.mem.startsWith(u8, text, word) and text.len > word.len and (text[word.len] == ' ' or text[word.len] == ':'))) return;
- p.fs.announced_seq = null;
- dropRecord(p, seq);
-}
-
/// Drops the newest record when it is a msg saying `text` (of any pane)
/// that no follower has read: a failure its write's err record will say.
pub fn dropMessage(p: *Pardes, text: []const u8) void {
@@ -1100,13 +1049,10 @@ test "the log records whether or not anyone reads, and an open that follows wait
try std.fmt.bufPrint(&expected, "msg {d} save: two lines\n", .{serial}),
rdf.next(p, log, fh, frozen).bytes,
);
- // A builtin announces itself on the message row, so the log says which ran.
+ // A builtin announces itself on the message row only: the log has
+ // what it did.
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status);
try testing.expectEqualStrings(
- try std.fmt.bufPrint(&expected, "msg {d} Del\n", .{serial}),
- rdf.next(p, log, fh, frozen).bytes,
- );
- try testing.expectEqualStrings(
try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}),
rdf.next(p, log, fh, frozen).bytes,
);
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 4370d401..5ed3f6f4 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -50,9 +50,11 @@ pub const State = struct {
event_reader: bool = false,
events: events.Queue = .{},
nomark: bool = false,
- /// The revision a truncation of `data`, `xdata` or `body` left: the
- /// write that follows it at once joins its undo step, so a shell's
- /// `printf x > data` is one step, as acme's replace is.
+ /// The revision a truncation or a write of `data`, `xdata` or `body`
+ /// left: the write that follows it at once joins its undo step, until
+ /// that open is released (tree.zig releaseHandle). So a shell's
+ /// `printf 'x\ny\n' > data` is one step, as acme's replace is, though
+ /// bash writes it a line at a time; any other edit between breaks it.
joined: ?u32 = null,
noscroll: bool = false,
tag_snap: std.ArrayList(u8) = .empty,
@@ -454,6 +456,7 @@ fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const at = bodyOf(pane).len;
const take = spliceBody(p, pane, at, at, req.data) orelse
return Reply.fail(req.tag, E.NOMEM);
+ pane.fs.joined = fileOf(pane).?.revision;
if (!pane.fs.noscroll) showOffset(pane, at + take);
return .{ .tag = req.tag, .written = @intCast(take) };
}
@@ -508,6 +511,7 @@ fn writeData(p: *Pardes, req: Req, pane: *Pane) Reply {
const before = dotOf(pane);
const take = spliceBody(p, pane, q0, q1, req.data) orelse
return Reply.fail(req.tag, E.NOMEM);
+ pf.joined = fileOf(pane).?.revision;
setDot(pane, .{
.q0 = shiftOne(before.q0, clip(q0), clip(q1 - q0), clip(take)),
.q1 = shiftOne(before.q1, clip(q0), clip(q1 - q0), clip(take)),
@@ -683,7 +687,7 @@ pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !
look.invalidateLookHover(p, id);
p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } });
events.noteLog(p, .rename, pane);
- if (f.output == null) p.recent.opened(p.recentGpa(), f.path);
+ if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path);
}
/// Diagnostics go to the +Errors buffer of the pane's directory.
@@ -725,6 +729,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
const pf = &pane.fs;
switch (file) {
.body => if (fileOf(pane) != null) {
+ pf.joined = null;
_ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err;
pf.joined = fileOf(pane).?.revision;
pf.addr = .{};
@@ -735,6 +740,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
const q0 = pf.addr.q0;
const q1 = @max(q0, pf.addr.q1);
const before = dotOf(pane);
+ pf.joined = null;
_ = spliceBody(p, pane, q0, q1, "") orelse return .err;
pf.joined = fileOf(pane).?.revision;
setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) });
@@ -1030,6 +1036,25 @@ test "the flag files read and write the state acme kept behind ctl verbs" {
try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno());
}
+test "the writes of one open are one undo step, as bash's line-buffered printf makes them; the next open is another" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ for ([_]u64{ Node.of(serial, .data), Node.of(serial, .body), Node.of(serial, .xdata) }) |node| {
+ _ = wr(p, Node.of(serial, .addr), "$");
+ for ([_][]const u8{ "x\n", "y\n", "z\n" }) |piece| try testing.expectEqual(Status.ok, wr(p, node, piece).reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = node });
+ // A second open: its own step.
+ _ = wr(p, Node.of(serial, .addr), "$");
+ try testing.expectEqual(Status.ok, wr(p, node, "w\n").reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = node });
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\nx\ny\nz\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+ }
+}
+
test "a replace through data, truncate then write, is one undo step" {
const p = try withFile(testing.allocator, "one two\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index aa0f499a..bf5f432c 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -67,6 +67,14 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
const dir = pane.cwdSlice();
return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}: no such directory", .{dir[0..@min(dir.len, 256)]}) catch "exec: no such directory");
};
+ // Nor is a shell that is not there, or a script whose
+ // interpreter is not: refused, and the running shell kept.
+ if (comptime pardes.hosted) if (!apply and std.mem.eql(u8, line, "exec")) if (p.panes[id]) |pane| {
+ const bin = pane.shell orelse p.shellBin();
+ var why: [320]u8 = undefined;
+ if (@import("../host_io.zig").Shell.refusal(bin[0..@min(bin.len, 200)], &why)) |refused|
+ return tree.failText(req.tag, E.NOENT, std.fmt.bufPrint(&p.fs.ename, "exec: {s}", .{refused}) catch "exec: no such shell");
+ };
if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 64089c01..a8b86a96 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -831,6 +831,10 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
+ // The writes of one open are one undo step (pane.zig `joined`).
+ if (t == .pane and (t.pane.file == .body or t.pane.file == .data or t.pane.file == .xdata)) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.joined = null;
+ };
// `: > limit` clears it for good once its open goes.
if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
p.panes[id].?.fs.limit_truncated = null;