summaryrefslogtreecommitdiff
path: root/src/ninep
diff options
context:
space:
mode:
Diffstat (limited to 'src/ninep')
-rw-r--r--src/ninep/addr.zig22
-rw-r--r--src/ninep/cols.zig26
-rw-r--r--src/ninep/ctl.zig158
-rw-r--r--src/ninep/events.zig5
-rw-r--r--src/ninep/pane.zig52
-rw-r--r--src/ninep/tree.zig6
6 files changed, 259 insertions, 10 deletions
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index df2cfaf0..b78c46e7 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -498,6 +498,28 @@ test "regular expressions search lines as sam's do, and a search wraps" {
try testing.expectEqual(@as(u32, 5), q.panes[0].?.fs.addr.q0);
}
+test "a refused write to dot after a truncation changes nothing; a truncation with no write empties it at close" {
+ const p = try th.withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const dot = Node.of(serial, .dot);
+ try testing.expectEqual(Status.ok, th.wr(p, dot, "2").reply.status);
+ const was = th.rd(p, dot, 0, 64).bytes;
+ var kept: [64]u8 = undefined;
+ @memcpy(kept[0..was.len], was);
+ // A shell's `echo /nomatch/ > dot`: truncate, then the refused write.
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = dot, .omode = 1 }).reply.handle;
+ _ = th.call(p, .{ .tag = 2, .op = .setattr, .node = dot, .truncate = true });
+ try testing.expectEqual(Status.err, th.call(p, .{ .tag = 3, .op = .write, .node = dot, .handle = h, .data = "/nomatch/" }).reply.status);
+ _ = th.call(p, .{ .tag = 4, .op = .release, .node = dot, .handle = h, .opened = true });
+ try testing.expectEqualStrings(kept[0..was.len], th.rd(p, dot, 0, 64).bytes);
+ // `: > dot`: the truncation alone empties it once its open goes.
+ const h2 = th.call(p, .{ .tag = 5, .op = .open, .node = dot, .omode = 1 }).reply.handle;
+ _ = th.call(p, .{ .tag = 6, .op = .setattr, .node = dot, .truncate = true });
+ _ = th.call(p, .{ .tag = 7, .op = .release, .node = dot, .handle = h2, .opened = true });
+ try testing.expectEqualStrings(" 0 0 ", th.rd(p, dot, 0, 64).bytes);
+}
+
test "a failed address leaves none, so data refuses rather than act at the last one" {
const p = try th.withFile(testing.allocator, "one\ntwo\n");
defer p.deinit();
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index a1fe9ecc..7afcfbd8 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -232,6 +232,32 @@ 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 "Joincol is refused when the joined column cannot give each pane its tag and 2 rows" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const ctl = @intFromEnum(tree.TopFile.ctl);
+ const focus = @intFromEnum(tree.TopFile.focus);
+ // Two panes in the first column, then a second column of two.
+ const a = try th.newPane(p);
+ _ = a;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Newcol\n").reply.status);
+ const b = try th.newPane(p);
+ var digits: [16]u8 = undefined;
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{b}));
+ _ = try th.newPane(p);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expect(p.col_n[0] >= 2 and p.col_n[1] >= 2);
+ // Focus in the left column: joined, four panes want 12 rows of 10.
+ const left = p.panes[p.col_panes[0][0]].?.serial;
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{left}));
+ const r = th.wr(p, ctl, "Joincol\n");
+ try testing.expectEqual(tree.Status.err, r.reply.status);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "tag and 2 rows") != null);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expect(pardes.layout.minimaFit(p));
+}
+
test "pane/new takes rows from a pane in another column before refusing ENOSPC" {
const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 16 });
defer p.deinit();
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 118b6488..75a7eb9a 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -491,8 +491,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
},
};
if (takes) return null;
- var why: [104]u8 = undefined;
- return refuse(p, req, if (config.Runtime.choices(setting.action)) |values|
+ var why: [160]u8 = undefined;
+ return refuse(p, req, if (config.Runtime.takes(setting.action)) |values|
std.fmt.bufPrint(&why, "bad value in control message; takes {s}", .{values}) catch "bad value in control message"
else
"bad value in control message", line);
@@ -554,7 +554,9 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
const word = setting.word;
switch (setting.action) {
.toggle => |field| try w.print("{s} {s}\n", .{ word, if (@field(s, @tagName(field))) "on" else "off" }),
- .theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }),
+ .theme => if (s == &p.settings) {
+ if (pardes.colors.loadedThemeFile(p)) |path| try w.print("ThemeFile {s}\n", .{path}) else try w.print("{s} {s}\n", .{ word, p.theme().name });
+ } else try w.print("{s} {s}\n", .{ word, pardes.themes[s.theme].name }),
.shell => {
const chosen = s.shell.requested.get();
try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.defaultShell() });
@@ -1254,6 +1256,11 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename);
+ // A number's refusal names its range, a path's what it takes.
+ try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"InactiveDim 200\"", wr(p, root_ctl, "InactiveDim 200").reply.ename);
+ try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"MessageLinger x\"", wr(p, root_ctl, "MessageLinger x").reply.ename);
+ for (config.Runtime.settings) |setting| if (setting.action != .theme)
+ try testing.expect(config.Runtime.takes(setting.action) != null);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme no-such-theme\""));
@@ -1447,7 +1454,7 @@ test "the root look's file:/re/ goes on from the dot, and a miss leaves the dot
try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
}
-test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an ellipsis" {
+test "a look's miss repeats the path whole up to 256 bytes, then gives up its middle to an ellipsis" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
defer p.deinit();
@@ -1456,9 +1463,9 @@ test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an
var line: [512]u8 = undefined;
_ = wr(p, root, try std.fmt.bufPrint(&line, "{s}\n", .{long}));
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&line, "look: no match for \"{s}\"\n", .{long})));
- const huge = "é" ** 150; // 300 bytes: cut at 256, between characters
+ const huge = "é" ** 150; // 300 bytes: its middle cut to fit 256, between characters
_ = wr(p, root, huge ++ "\n");
- try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 128 ++ "…\"\n"));
+ try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 63 ++ "…" ++ "é" ** 63 ++ "\"\n"));
}
test "exec runs a builtin at the pane and records the pane it acted on" {
@@ -1658,6 +1665,49 @@ test "Save path of a file pane logs a save record naming that path" {
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "save {d} /tmp/pardes-save-copy.txt\n", .{serial})));
}
+test "Save and Save path on an image pane are refused with why, never a silent success" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const id = p.freeSlot().?;
+ const doc = try p.newDocPane(id);
+ doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") };
+ for ([_][]const u8{ "Save\n", "Save /tmp/pardes-copy.ppm\n" }) |line| {
+ const r = wr(p, Node.of(doc.serial, .ctl), line);
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expectEqualStrings("Save: an image pane has no text to save", r.reply.ename);
+ }
+}
+
+test "a builtin for one kind of pane is refused on another, and still acts on its own kind" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "text\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const cases = [_]struct { []const u8, []const u8 }{
+ .{ "Petscii\n", "Petscii: only an image pane takes it" },
+ .{ "Palette\n", "Palette: only an image pane takes it" },
+ .{ "Ascii\n", "Ascii: only an image pane takes it" },
+ .{ "Filter\n", "Filter: only a terminal pane takes it" },
+ .{ "Togglettymode\n", "Togglettymode: only a terminal pane takes it" },
+ };
+ for (cases) |case| {
+ const r = wr(p, Node.of(serial, .ctl), case[0]);
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expectEqualStrings(case[1], r.reply.ename);
+ }
+ if (pardes.pdf_enabled) for ([_][]const u8{ "PdfFit\n", "PdfTint\n", "PdfSections\n" }) |line| {
+ try testing.expectEqual(Status.err, wr(p, Node.of(serial, .ctl), line).reply.status);
+ };
+ const id = p.freeSlot().?;
+ const doc = try p.newDocPane(id);
+ doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") };
+ const r = wr(p, Node.of(doc.serial, .ctl), "Mode\n");
+ try testing.expectEqualStrings("Mode: only a text or terminal pane has modes", r.reply.ename);
+ const glyphs = doc.image.?.glyph_art;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(doc.serial, .ctl), "Petscii\n").reply.status);
+ try testing.expect(doc.image.?.glyph_art != glyphs);
+}
+
test "exec Save writes the named file through the host" {
const gpa = testing.allocator;
const p = try withFile(gpa, "one\ntwo\n");
@@ -1786,6 +1836,64 @@ test "an error's words give the errno a mount reads: EINVAL for what is malforme
}
}
+test "DumpConfig's whole report writes back to the root ctl, line by line, with no error" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "DumpConfig\n").reply.status);
+ const report = for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ const o = f.output orelse continue;
+ if (std.meta.eql(o.from, panes.Output.Origin{ .cmd = .DumpConfig })) break try testing.allocator.dupe(u8, f.content);
+ } else return error.NoDumpConfigPane;
+ defer testing.allocator.free(report);
+ // What this shell cannot show is a comment, never a value it refuses.
+ if (pardes.platform == .tty) try testing.expect(std.mem.indexOf(u8, report, "# GripWidth unsupported\n") != null);
+ var lines = std.mem.splitScalar(u8, report, '\n');
+ while (lines.next()) |line| {
+ if (line.len == 0) continue;
+ const r = wr(p, root_ctl, line);
+ if (r.reply.status != .ok) std.debug.print("refused: {s}: {s}\n", .{ line, r.reply.ename });
+ try testing.expectEqual(Status.ok, r.reply.status);
+ }
+}
+
+test "after a ThemeFile the root ctl and DumpConfig say ThemeFile and its path, which writes back" {
+ if (comptime !pardes.hosted) return;
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const path = "/tmp/pardes-mine-theme.zon";
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "ThemeFile " ++ path ++ "\n").reply.status);
+ var custom = pardes.themes[0];
+ custom.name = "mine";
+ var encoded: std.Io.Writer.Allocating = .init(gpa);
+ defer encoded.deinit();
+ try std.zon.stringify.serialize(custom, .{}, &encoded.writer);
+ try testing.expect(pardes.colors.loadThemeFile(p, p.theme_file_generation, encoded.written(), false));
+ const settings = rd(p, root_ctl, 0, 1 << 16).bytes;
+ try testing.expect(std.mem.indexOf(u8, settings, "ThemeFile " ++ path ++ "\n") != null);
+ try testing.expect(std.mem.indexOf(u8, settings, "Theme mine") == null);
+ var storage: [16384]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
+ try config.Runtime.writeReport(&out, .{
+ .startup_config_path = null,
+ .platform = @tagName(pardes.platform),
+ .theme_name = p.theme().name,
+ .theme_file = pardes.colors.loadedThemeFile(p),
+ .compiled_default_shell = config.defaultShell(),
+ .hover_delay_frames = null,
+ .native_images = false,
+ .capabilities = builtins.capabilities,
+ .state = &p.settings,
+ });
+ try testing.expect(std.mem.indexOf(u8, out.buffered(), "ThemeFile " ++ path ++ "\n") != null);
+ // Written back, it is the same request again, not "no theme mine".
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "ThemeFile " ++ path ++ "\n").reply.status);
+}
+
test "Joincol with no column to the right and Theme with no such theme say so" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
@@ -2909,6 +3017,23 @@ test "a column takes panes until their documented minimums fill it, not until no
}
}
+test "Jumplist fills its one +Jumps again, as Recent does, never a second" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var count: [2]usize = undefined;
+ for (&count) |*n| {
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Jumplist\n").reply.status);
+ p.sync();
+ n.* = 0;
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| {
+ n.* += @intFromBool(std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist }));
+ };
+ }
+ try testing.expectEqual(@as(usize, 1), count[0]);
+ try testing.expectEqual(@as(usize, 1), count[1]);
+}
+
test "a Jumplist entry is (closed) only while no pane has its file open" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -2935,6 +3060,27 @@ test "a Jumplist entry is (closed) only while no pane has its file open" {
for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0);
}
+test "Recent's row for a file still open is where its dot is now, not where it last closed" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:3\n", .{dir})).reply.status);
+ p.sync();
+ const pane = p.panes[p.active].?;
+ pane.body.vsel.active = false; // the look's selection of line 3 let go
+ pane.body.cur_row = 1;
+ pane.body.cur_col = 2;
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status);
+ const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ var want: [4300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}/a.txt:2:3 open\n", .{dir})) != null);
+}
+
test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index a09f7278..bacf3ad6 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -140,8 +140,9 @@ pub fn announce(p: *Pardes) void {
if (!pane.fs.unannounced) continue;
pane.fs.unannounced = false;
noteLog(p, .new, pane);
- // A file opened (a look, a Restore, a boot): first in Recent.
- if (pane.file) |f| if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path);
+ // A file, PDF or image opened (a look, a Restore, a boot): first in
+ // Recent.
+ if (pardes.Pardes.recentPlace(pane)) |d| if (pardes.Pardes.recentKeeps(d.path)) p.recent.opened(p.recentGpa(), d.path);
}
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 78bec09f..ce6d0422 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -44,6 +44,10 @@ pub const State = struct {
/// The limit a truncation cleared, kept until the write that follows it
/// on that open (a shell's `>`): a write that fails puts it back.
limit_truncated: ?Range = null,
+ /// `dot` was truncated and nothing written since: the write that
+ /// follows sets it or, refused, leaves it as it was; with none, the
+ /// open's release empties it (tree.zig releaseHandle).
+ dot_truncated: bool = false,
/// Opens of `event`, which hold the pane scripted.
readers: u16 = 0,
/// One of `readers` reads `event`; a second reading open is refused.
@@ -286,6 +290,9 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
const take = if (bytes.len == 0) 0 else wholeUtf8(bytes);
const lo = @min(q0, f.content.len);
const hi = @max(lo, @min(q1, f.content.len));
+ // An empty range deleted, or a range replaced by what it holds: no
+ // edit, so no undo step and no new revision.
+ if (std.mem.eql(u8, f.content[lo..hi], bytes[0..take])) return take;
const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null;
@memcpy(new[0..lo], f.content[0..lo]);
@memcpy(new[lo..][0..take], bytes[0..take]);
@@ -297,6 +304,41 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
return take;
}
+test "a write that changes nothing, an empty range deleted or a range replaced by itself, makes no undo step" {
+ const p = try withFile(testing.allocator, "abc\ndef\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const serial = pane.serial;
+ const f = &pane.file.?;
+ const steps = f.history.undo_len;
+ const revision = f.revision;
+ const data = Node.of(serial, .data);
+ // Each as a shell does it, on an open of its own: `: > data` after
+ // an empty address, then `echo abc > data` over the line it holds.
+ _ = wr(p, Node.of(serial, .addr), "#2,#2");
+ const h = call(p, .{ .tag = 1, .op = .open, .node = data, .omode = 1 }).reply.handle;
+ try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 2, .op = .setattr, .node = data, .truncate = true }).reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = data, .handle = h, .opened = true });
+ for ([_][]const u8{ "abc\n", "abd\n" }) |text| {
+ _ = wr(p, Node.of(serial, .addr), "1");
+ const w = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle;
+ try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 5, .op = .write, .node = data, .handle = w, .data = text }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = w, .opened = true });
+ if (text[2] == 'c') {
+ try testing.expectEqualStrings("abc\ndef\n", f.content);
+ try testing.expectEqual(steps, f.history.undo_len);
+ try testing.expectEqual(revision, f.revision);
+ }
+ }
+ // Edit's `s/d/d/` is the same nothing.
+ const undone = f.history.undo_len;
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,s/d/d/\n").reply.status);
+ try testing.expectEqual(undone, f.history.undo_len);
+ // A real change is still one.
+ try testing.expectEqualStrings("abd\ndef\n", f.content);
+ try testing.expectEqual(steps + 1, f.history.undo_len);
+}
+
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();
@@ -800,9 +842,11 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
pf.limit = kept;
pf.limit_truncated = null;
};
+ if (file == .dot) pf.dot_truncated = false;
return tree.failText(req.tag, E.INVAL, a.err);
};
if (file == .limit) pf.limit_truncated = null;
+ if (file == .dot) pf.dot_truncated = false;
switch (file) {
.addr => {
pf.addr = range;
@@ -1000,7 +1044,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
pf.limit_truncated = pf.limit;
pf.limit = null;
},
- .dot => if (fileOf(pane) != null) setDot(pane, .{}),
+ .dot => pf.dot_truncated = fileOf(pane) != null,
else => {},
}
return .ok;
@@ -1211,8 +1255,12 @@ test "sel reads the selection and writing sel replaces it" {
try testing.expectEqual(@as(u32, 0), p.panes[0].?.fs.addr.q0);
try testing.expectEqual(@as(u32, 3), p.panes[0].?.fs.addr.q1);
- // A truncation empties dot; an address expression addresses it.
+ // A truncation empties dot once its open goes, nothing written after
+ // it; an address expression addresses it.
+ const h = call(p, .{ .tag = 8, .op = .open, .node = dot, .omode = 1 }).reply.handle;
_ = call(p, .{ .tag = 9, .op = .setattr, .node = dot, .truncate = true });
+ try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes);
+ _ = call(p, .{ .tag = 10, .op = .release, .node = dot, .handle = h, .opened = true });
try testing.expectEqualStrings("", rd(p, sel, 0, 100).bytes);
try testing.expectEqual(Status.ok, wr(p, dot, "#0,#3").reply.status);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 4300d2b5..c0ace5be 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -949,6 +949,12 @@ fn releaseHandle(p: *Pardes, req: Req) void {
if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
p.panes[id].?.fs.limit_truncated = null;
};
+ // `: > dot` empties it once its open goes, no write having set it.
+ if (t == .pane and t.pane.file == .dot) if (p.paneBySerial(t.pane.serial)) |id| {
+ const pn = p.panes[id].?;
+ if (pn.fs.dot_truncated) pane.setDot(pn, .{});
+ pn.fs.dot_truncated = false;
+ };
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;