summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md9
-rw-r--r--build.zig.zon2
-rw-r--r--docs/config.md7
-rw-r--r--docs/fs.md40
-rw-r--r--src/9p_io.zig45
-rw-r--r--src/CHANGELOG.md26
-rw-r--r--src/File.zig46
-rw-r--r--src/Messages.zig10
-rw-r--r--src/Output.zig10
-rw-r--r--src/builtins.zig3
-rw-r--r--src/config.zig165
-rw-r--r--src/dump.zig3
-rw-r--r--src/exec.zig8
-rw-r--r--src/fs.zig9
-rw-r--r--src/look.zig5
-rw-r--r--src/lsp/lsp.zig26
-rw-r--r--src/lsp/lsp_client.zig14
-rw-r--r--src/main.zig10
-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
-rw-r--r--src/pardes.zig93
-rw-r--r--src/regexp.zig10
-rw-r--r--src/sam_edit.zig1
-rw-r--r--src/terminal.zig17
-rw-r--r--test/appearance_gallery.py2
-rw-r--r--test/font_size.py6
-rw-r--r--test/fs.py16
-rw-r--r--test/gui-goldens.txt34
-rw-r--r--test/monkey9p.py6
-rw-r--r--test/output.zig26
-rw-r--r--test/perf-baseline-gui-Debug.json2
-rw-r--r--test/perf-baseline-tty-Debug.json2
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json2
-rw-r--r--test/perf.zig21
-rw-r--r--test/snapshots/builtins.golden2
-rw-r--r--test/snapshots/leader.golden2
-rw-r--r--test/snapshots/tutor.golden2
41 files changed, 926 insertions, 264 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 7cdca1a0..85f26ca5 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -94,6 +94,15 @@ echo 'sig INT' > "$m/pane/$t/pty/ctl" # interrupt
`busy: <program> is running` means the prompt is not free: a program holds
the terminal, so talk to it through `pty/data`.
+Ask the language server at a file pane's cursor (`addr` then `dot=addr`):
+
+```sh
+echo '/myFunc/' > "$m/pane/$n/addr"; echo 'dot=addr' > "$m/pane/$n/ctl"
+echo Hover > "$m/pane/$n/exec" # +Hover; Rename new, Diagnostics, Symbols
+echo Lspwhy > "$m/pane/$n/exec" # +Lsp: why the last query found what it did
+echo Lspinfo > "$m/pane/$n/exec" # which server, and its state
+```
+
## Follow what happens
```sh
diff --git a/build.zig.zon b/build.zig.zon
index c052341a..e80961ef 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .pardes,
- .version = "0.0.6",
+ .version = "0.0.7",
.dependencies = .{
// 9P sessions and transports live in the published cloud9 package, so
// the manifest pins the build: `zig build` fetches this commit into
diff --git a/docs/config.md b/docs/config.md
index bc4acf12..35f0b6e7 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -34,8 +34,11 @@ not stop later ones. Text that is no builtin is not run as a shell command
`Config` (`SPC f c`) opens `init` itself in a pane, or goes to its pane when
it is open. With no file there yet, the pane is named for it, empty, and
Save writes it, making its directory first. `DumpConfig` opens a
-`+DumpConfig` pane with the startup path (a right click opens it) and every
-live setting; a setting the frontend cannot show reads `unsupported`. The
+`+DumpConfig` pane with every live setting, each line the word that sets
+it and its value (`Shell /bin/bash`, `PanelSlide off`, `LocationsConfig …`)
+and `unsupported` for one the frontend cannot show. After a blank line
+come what is in effect but set by no word, such as the last shell spawned
+and the font in use, and the startup path (a right click opens it). The
root `ctl` file reads the settings back in the words a write takes
([fs.md](fs.md#the-root-ctl)).
diff --git a/docs/fs.md b/docs/fs.md
index 06fa1406..6a82b633 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -18,9 +18,11 @@ and `PARDES_PANE` (their pane's serial).
**Forwarding.** `pardes FILE` run in a pane (a live `PARDES_PID`, with
`PARDES_9P` and `PARDES_PANE`) writes FILE to that pane's `look` and returns
-at once, as acme's `B` does. FILE must already exist; a name that does not
-resolve, or a missing `PARDES_9P`/`PARDES_PANE`, starts a separate editor
-instead. Bare `pardes` in a pane refuses and names `--nested`.
+at once, as acme's `B` does. A FILE not there yet (`pardes notes/new.txt`)
+opens a new pane named for it, empty, and its Save creates the file, making
+its directories first when they are not there either. A missing
+`PARDES_9P`/`PARDES_PANE` starts a separate editor instead. Bare `pardes` in
+a pane refuses and names `--nested`.
`--wait` (`-w`) returns when the pane that shows FILE is deleted (exit 0) or
the session goes away (exit 1), as acme's `E` does. Use
@@ -156,7 +158,8 @@ fails at the close if it never ends (``unmatched `{'``). A line held past
1 MiB is refused.
**Answers.** Reading `look`, `exec` or `tagexec` answers the serials the
-last command made, or else the pane it acted on or focused, one a line. An
+last command made, or else the pane it acted on or focused, one a line;
+nothing when it did none of these (`Newcol` at `/tagexec`). An
open that wrote reads its own last answer; an open that never wrote reads
the session's last. A read is a stream: once read, the next read on that
fid is EOF until the next write (or seek to 0). With other clients about,
@@ -208,10 +211,19 @@ A line written to `exec` is a middle click:
- a builtin word runs (`/commands` lists them): `Save`, `Del`, `New`, `Tty
[shell]`, `Msg text`, `Find`, `Grep`, `Edit ...`, `Mount`, ...
A builtin that needs its argument (`Msg`, `Mount`, `Find`) written bare is
- `wrong #args in control message "Msg"`.
+ `wrong #args in control message "Msg"`, and so is one that takes none
+ written with one (`Config extra`).
+- the language server's words ask about a file pane's text at its cursor
+ (set it with `addr` and `dot=addr` first): `Hover` fills `+Hover`,
+ `Rename new` renames the symbol everywhere and lists the places,
+ `Diagnostics` and `Symbols` list the file's in `+Search`, `Lspinfo` says
+ which server serves the file and its state, and `Lspwhy` narrates the
+ last query step by step (in `+Lsp`), to tell why it found nothing. The
+ write returns once the answer is in; a pane with no file is refused.
- acme's words run as pardes's where it has one (`Put` is `Save`, `Delete`
a `Del` that does not ask); the rest (`Get`, `Putall`, `Snarf`, `Cut`,
- `Paste`, `Zerox`, `Sort`, `Load`, `ID`, `Send`) are refused, `invalid:
+ `Paste`, `Zerox`, `Sort`, `Load`, `ID`, `Send`, `Tab`, `Indent`, `Local`,
+ `Incl`, `Abort`) are refused, `invalid:
acme's Get is not a pardes builtin: ...`, never run as commands. So is a
GUI-only builtin (`Fonts`) on another frontend.
- anything else is a command line, at most 1024 bytes. At a terminal idle
@@ -311,7 +323,8 @@ makes one, and closing its last pane leaves it (the keyboard goes to its
tag, `focus` reads empty, the log says only `del`). Closing the session's
last pane quits pardes. The log says `newcol <serial>` and `delcol <serial>`.
-`tag` files (`/tag`, `/col/<n>/tag`, `/pane/<n>/tag`) read the whole tag.
+`tag` files (`/tag`, `/col/<n>/tag`, `/pane/<n>/tag`) read the whole tag,
+with no newline after it.
A pane's starts with its computed path (an image's with its mode words, a
PDF's with its page), then the editable text. `>` replaces the editable text
(the default words too: `echo Make > tag` leaves only `Make`) and drops the
@@ -320,7 +333,10 @@ blank matters; `echo` would start a second line). A pane tag may hold
several lines; a column or workspace tag is one, a newline written into it
becoming a space. Control characters other than tab, DEL, C1 controls and
non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at
-most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). A clear is an ordinary edit
+most 4096 bytes (`tag: no space: over 4096 bytes`, ENOSPC). All three kinds
+take the same checks, whole or not at all, and a `>` whose write is refused
+changes nothing: its truncation is done with the write that fits, or at the
+close (or a read) when none came. A clear is an ordinary edit
and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen.
## Panes
@@ -338,7 +354,8 @@ are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike.
**`name`** reads the file name (a terminal's directory); a write renames the
buffer (relative to the pane's directory) and marks nothing dirty; `Save`
-then writes under the new name. A name is one line; refused (EINVAL) are a
+then writes under the new name, making its directories first when the name
+went into one not there. A name is one line; refused (EINVAL) are a
second line, a blank at either end, control bytes and non-UTF-8 (`bad
character in file name: a blank at its start`, ...). Up to 255 bytes a
component.
@@ -452,8 +469,9 @@ The same code (`src/regexp.zig`) serves addresses, Edit, and normal mode's
submatches, so Edit's `s` has no `\1`-`\9`.
- A pattern holding `\n` runs over the whole text: there `^` may only come
first and `$` only just before a `\n`, else it is refused.
-- An alternation must anchor every branch or none (`^def|^ ` works,
- `^def|x` is refused).
+- An alternation must anchor every branch with `^` or none (`^def|^ `
+ works, `^def|x` is refused: `an alternation anchors every branch with ^
+ or none`). A `$` does not count: `foo$|bar` is fine.
- A class may hold non-ASCII runes (`[éa-z]`, a range up to 256 runes); a
wider range or a negated class with one (`[^é]`) is refused.
- At most 512 operations (about 512 characters, counted after that
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 78ff5d59..d1b2099c 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -851,6 +851,16 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [
return null;
};
if (!ensureSocketDir(dir)) return null;
+ const entry_name = if (named.len != 0) named else fallback;
+ // Checked before anything is made: the turn's hooks point at the
+ // listener from here on, and a path too long for sun_path used to free
+ // it with them still set, so the editor's next rest called into freed
+ // memory.
+ var path_check: [sun_path_len]u8 = undefined;
+ if (socketPath(&path_check, dir, entry_name) == null) {
+ log.warn("no socket for {s}: the path is too long for a unix socket", .{entry_name});
+ return null;
+ }
const l = gpa.create(Listener) catch return null;
l.* = .{ .io = io, .core = core };
pardes.turn.start(io);
@@ -863,11 +873,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [
.handler = .{ .ctx = l, .serve = Listener.onServe, .opened = Listener.onOpened, .refused = Listener.onRefused },
.greet_timeout_ms = Listener.greet_deadline_ms,
});
- const entry_name = if (named.len != 0) named else fallback;
- const p = socketPath(&l.path_buf, dir, entry_name) orelse {
- gpa.destroy(l);
- return null;
- };
+ const p = socketPath(&l.path_buf, dir, entry_name).?;
_ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: {
const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null;
if (err != error.AddressInUse or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) {
@@ -1261,6 +1267,35 @@ test "TCP addresses are numeric and normalize mapped IPv4" {
extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
extern "c" fn rmdir(path: [*:0]const u8) c_int;
+test "a socket path too long for sun_path is no listener, and leaves the editor's turn as it was" {
+ if (comptime !supported) return error.SkipZigTest;
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ var directory: [64:0]u8 = undefined;
+ _ = try std.fmt.bufPrintSentinel(&directory, "/tmp/pardes-long-XXXXXX", .{}, 0);
+ if (mkdtemp(&directory) == null) return error.TempDirectoryFailed;
+ const dir = std.mem.span(@as([*:0]const u8, &directory));
+ defer _ = rmdir(&directory);
+ // A name that fits: the directory is one a listener takes.
+ var fits: [sun_path_len]u8 = undefined;
+ try testing.expect(socketPath(&fits, dir, "short") != null);
+ const old = libc.getenv("XDG_RUNTIME_DIR");
+ var old_buf: [4096]u8 = undefined;
+ const old_copy = if (old) |o| try std.fmt.bufPrintSentinel(&old_buf, "{s}", .{std.mem.span(o)}, 0) else null;
+ var z: [4096]u8 = undefined;
+ _ = setenv("XDG_RUNTIME_DIR", try std.fmt.bufPrintSentinel(&z, "{s}", .{dir}, 0), 1);
+ defer if (old_copy) |o| {
+ _ = setenv("XDG_RUNTIME_DIR", o, 1);
+ } else {
+ _ = unsetenv("XDG_RUNTIME_DIR");
+ };
+ const long = "n" ** 120;
+ try testing.expect(listen(testing.io, testing.allocator, p, long, "", null, null) == null);
+ try testing.expect(pardes.turn.answer_held == null);
+ try testing.expect(pardes.turn.wake_parked == null);
+ try testing.expect(pardes.turn.io == null);
+}
+
test "a listening editor posts itself into the 9P registry and unposts on stop" {
if (comptime !supported) return error.SkipZigTest;
const gpa = testing.allocator;
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 42f2eb6f..3dd7c996 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,31 @@
# Changelog
+## 0.0.7
+
+- `lapis` draws its cells unblended and each plaque pixel once: about a
+ third less GPU time a frame at 4K, pixel for pixel the same. `lapis_plain`
+ is lapis on flat grounds, keeping its gold frames, plaque shadows and
+ file-name shadow.
+- Fixed a crash at startup when the 9P socket path is longer than the
+ system allows, and a leak when a terminal pane closes.
+- Pane, column and workspace tags share one set of write checks: the 4096
+ byte limit, control characters, and a refused `>` write leaves the tag as
+ it was. Column and workspace tags read without a trailing newline.
+- A control character in a write to any ctl file refuses the whole write.
+- Restore never reuses a column serial; the log announces a pane's `new`
+ before anything else about it.
+- `DumpConfig` prints each setting as the ctl word that sets it, and
+ `unsupported` for one the frontend lacks.
+- `New` and `Tty` run from the workspace and column tags over 9P, with no
+ panes too; `Help` and `Find` there act in the session's directory.
+- `pardes NEWFILE` forwards into a directory that does not exist yet; Save
+ makes it. The Tutor pane is `<dir>/+Tutor`.
+- Long-path errors keep their reason; `name` refuses a component over 255
+ bytes; regex refusals of an unanchored branch say why; acme's `Tab`,
+ `Indent`, `Local`, `Incl` and `Abort` are refused, not run as commands.
+- `Lspwhy` explains the last language-server query, whatever its kind.
+- Bulk body writes over 9P are about 15x faster per 60 KB write.
+
## 0.0.6
- A middle or right sweep in a column tag or the workspace tag runs or looks
diff --git a/src/File.zig b/src/File.zig
index e8a9e2f8..43b921e3 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -57,6 +57,9 @@ pub const State = struct {
/// replaces it: an edit that brings the text back to it is clean again,
/// as an undo to the save point is in acme.
saved_hash: ?u64 = null,
+ /// The length of the text `saved_hash` is of, when it is of one: an
+ /// edit to another length cannot be back to it, and is not hashed.
+ saved_len: ?usize = null,
watch_after_save: bool = false,
/// Save makes the file's directory, and any above it, first: `Config`'s
/// pane for an init file that is not there yet, whose directory may not
@@ -617,14 +620,22 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize {
/// The line index of `new` from `old`'s: an edit replaces one span, so
/// line starts before it stand, starts after it shift, and only the span
/// is scanned. Rebuilding scanned the whole file on every keystroke.
-fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize {
+/// The bytes a splice left alone at either end, when its caller knows them.
+pub const Span = struct { head: usize, tail: usize };
+
+fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8, known: ?Span) ![]usize {
const n = @min(old.len, new.len);
var head: usize = 0;
- while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64;
- while (head < n and old[head] == new[head]) head += 1;
var tail: usize = 0;
- while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64;
- while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1;
+ if (known) |span| {
+ head = span.head;
+ tail = span.tail;
+ } else {
+ while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64;
+ while (head < n and old[head] == new[head]) head += 1;
+ while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64;
+ while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1;
+ }
// A start s follows the newline at s-1: kept while that newline is in
// the common head, shifted while it is in the common tail.
const kept = std.sort.upperBound(usize, starts, head, struct {
@@ -677,7 +688,12 @@ test "edited line index equals a rebuilt one" {
const old = try gpa.dupe(u8, text.items);
defer gpa.free(old);
try text.replaceRange(gpa, at, del, ins[0..ins_len]);
- const edited = try editedLineStarts(gpa, starts, old, text.items);
+ // As a splice that says what it kept, and as one that does not.
+ const known: Span = .{ .head = at, .tail = old.len - at - del };
+ const told = try editedLineStarts(gpa, starts, old, text.items, known);
+ defer gpa.free(told);
+ const edited = try editedLineStarts(gpa, starts, old, text.items, null);
+ try std.testing.expectEqualSlices(usize, edited, told);
defer gpa.free(edited);
var fresh_state: State = undefined;
fresh_state.content = text.items;
@@ -906,6 +922,12 @@ fn reportEdit(p: *Pardes, f: *State, new: []const u8) void {
}
pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
+ setContentSpan(p, f, new, null);
+}
+
+/// `setContent` for a splice that knows what it left alone at either end,
+/// so the line index is not found again by comparing the whole text.
+pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void {
locations.freeRows(p.gpa, f.location_rows);
f.location_rows = &.{};
for (p.panes) |slot| if (slot) |pane| {
@@ -919,13 +941,17 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void {
reportEdit(p, f, new);
if (f.mini) |*mini| mini.deinit(p.gpa);
f.mini = null;
- const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{};
- // ponytail: a whole-text hash per edit; a rolling hash when files grow large.
- if (f.revision == f.saved_revision or f.saved_hash == null) f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new, span) catch &.{} else &.{};
+ // ponytail: a whole-text hash per edit that keeps the saved length; a
+ // rolling hash when that too matters.
+ if (f.revision == f.saved_revision or f.saved_hash == null) {
+ f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.saved_len = f.content.len;
+ }
p.gpa.free(f.content);
f.content = new;
f.revision +%= 1;
- if (f.saved_hash) |h| if (std.hash.Wyhash.hash(0, new) == h) {
+ if (f.saved_hash) |h| if ((f.saved_len orelse new.len) == new.len and std.hash.Wyhash.hash(0, new) == h) {
f.saved_revision = f.revision;
};
f.mtime = pardes.ctlfs.events.now();
diff --git a/src/Messages.zig b/src/Messages.zig
index d1478ac7..3eeb8c92 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -463,7 +463,10 @@ pub fn dialReason(err: anyerror) ?[]const u8 {
}
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
- var buf: [256]u8 = undefined;
+ // Room for an operation naming the longest path and the reason after
+ // it: a shorter one cut the reason off (`Save /long…`), and the reason
+ // is what a failure is for. Where it must be shorter, the path gives.
+ var buf: [@max(256, limits.host_path_cap + 128)]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
// A peer out of reach says so, not the error's name (`dial`).
@@ -503,6 +506,11 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
p.fs.failures +%= 1;
+ var fitted: @TypeOf(p.fs.said) = undefined;
+ const said = pardes.ctlfs.fitErr(text, &fitted);
+ @memcpy(p.fs.said[0..said.len], said);
+ p.fs.said_len = @intCast(said.len);
+ p.fs.said_at = p.fs.failures;
// A builtin a ctl write runs: its first error is also the write's, its
// path shortened in the middle if it must be, never its reason.
const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits;
diff --git a/src/Output.zig b/src/Output.zig
index 0581be41..545637b3 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -815,7 +815,11 @@ pub fn openConfig(p: *Pardes, id: usize) !void {
errdefer out.deinit();
var dir_buf: [1024]u8 = undefined;
const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4);
+ var located: std.Io.Writer.Allocating = .init(p.gpa);
+ defer located.deinit();
+ try p.locations_config.write(&located.writer);
try config.Runtime.writeReport(&out.writer, .{
+ .locations = located.written(),
.default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "",
.startup_config_path = p.opts.startup_config_path,
.platform = @tagName(pardes.platform),
@@ -912,7 +916,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void {
fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) !void {
errdefer p.gpa.free(content);
- const pane = p.panes[id] orelse return error.MissingPane;
+ if (p.panes[id] == null) return error.MissingPane;
for (p.panes, 0..) |slot, i| {
const hp = slot orelse continue;
const hf = if (hp.file) |*f| f else continue;
@@ -924,7 +928,9 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8)
p.active = i;
return;
}
- const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
+ // Where the word ran: the pane's directory, or from a column's or the
+ // workspace's tag, the session's.
+ const dir = p.execDir(id);
const free = p.freeSlot() orelse return error.NoPaneSlots;
_ = try open(p, free, dir, from, arg, content);
exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc);
diff --git a/src/builtins.zig b/src/builtins.zig
index c40b2e00..8fe49649 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -458,7 +458,8 @@ test "Ligatures is a setting only in the shell that shapes text" {
.state = &p.settings,
});
try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures off\n") != null);
- try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures") != null);
+ // A GUI-only setting reads so elsewhere, as every one the shell lacks.
+ try std.testing.expectEqual(!exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures unsupported\n") != null);
}
comptime {
diff --git a/src/config.zig b/src/config.zig
index a70ae708..94b32b88 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1275,6 +1275,8 @@ pub const Runtime = struct {
native_images: bool,
capabilities: Capabilities,
state: *const Runtime,
+ /// The `LocationsConfig` argument in effect, as it would be typed.
+ locations: []const u8 = "",
};
fn onOff(value: bool) []const u8 {
@@ -1285,23 +1287,19 @@ pub const Runtime = struct {
return if (text.len == 0) "(none)" else text;
}
- fn transitionSettingName(transition: animation.Transition) []const u8 {
- if (transition == .off) return "off";
- return findAction(.{ .transition = transition }).?.word;
- }
-
- /// Each line reads as the command that would set it: the word, then the
- /// value you would type after it. No colons, no units — `WindowOpacity 70`
- /// is both what DumpConfig prints and what you write back.
+ /// Each setting line reads as the command that would set it: the word,
+ /// then the value you would type after it (`unsupported` where this
+ /// shell has no such setting). No colons, no units: `WindowOpacity 70`
+ /// is both what DumpConfig prints and what you write back. What is in
+ /// effect but set by no word follows, after a blank line.
pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void {
const state = context.state;
- var wrote_transition = false;
for (settings) |setting| switch (setting.action) {
.toggle => |field| {
- // A toggle for what this shell does not draw (Ligatures in a
- // terminal, whose text its emulator draws) is no setting here:
- // it is not a builtin, so the report does not list it either.
- if (!setting.enabled(context.capabilities)) continue;
+ if (!setting.enabled(context.capabilities)) {
+ try out.print("{s} unsupported\n", .{setting.word});
+ continue;
+ }
const value = switch (field) {
inline else => |f| @field(state, @tagName(f)),
};
@@ -1310,11 +1308,7 @@ pub const Runtime = struct {
.theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }),
.window_opacity => {
if (setting.enabled(context.capabilities))
- try out.print("{s} {d}{s}\n", .{
- setting.word,
- state.window_opacity,
- if (state.window_opacity_pending) " (pending)" else "",
- })
+ try out.print("{s} {d}\n", .{ setting.word, state.window_opacity })
else
try out.print("{s} unsupported\n", .{setting.word});
},
@@ -1333,64 +1327,28 @@ pub const Runtime = struct {
} }),
.shell => {
const chosen = state.shell.requested.get();
- try out.print(
- "{s} requested (new panes) {s}{s}\n" ++
- "{s} effective (last spawn) {s}\n" ++
- "{s} pending {s}\n",
- .{
- setting.word,
- if (chosen.len == 0) context.compiled_default_shell else chosen,
- if (chosen.len == 0) " (default)" else "",
- setting.word,
- shown(state.shell.effective.get()),
- setting.word,
- onOff(state.shell.pending),
- },
- );
+ try out.print("{s} {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen });
},
.font => if (!setting.enabled(context.capabilities))
try out.print("{s} unsupported\n", .{setting.word})
else {
- if (state.font.requested_size_hundredths == 0)
- try out.print("{s} requested size unchanged\n", .{setting.word})
- else
- try out.print("{s} requested size {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 });
- try out.print(
- "{s} requested {s}\n" ++
- "{s} requested path {s}\n" ++
- "{s} effective {s}\n" ++
- "{s} pending {s}\n" ++
- "{s} effective size {d}.{d:0>2} {s}\n",
- .{
- setting.word,
- shown(state.font.requested_name.get()),
- setting.word,
- shown(state.font.requested_path.get()),
- setting.word,
- shown(state.font.effective_name.get()),
- setting.word,
- onOff(state.font.pending),
- setting.word,
- state.font.effective_size_hundredths / 100,
- state.font.effective_size_hundredths % 100,
- @tagName(state.font.effective_size_unit),
- },
- );
+ // As `Font` takes it, `name:size`: the one asked for, else
+ // the one in use.
+ const f = &state.font;
+ const name = if (f.requested_path.get().len > 0) f.requested_path.get() else if (f.requested_name.get().len > 0) f.requested_name.get() else f.effective_name.get();
+ try out.print("{s} {s}", .{ setting.word, name });
+ if (f.requested_size_hundredths != 0) try out.print(":{d}.{d:0>2}", .{ f.requested_size_hundredths / 100, f.requested_size_hundredths % 100 });
+ try out.writeByte('\n');
},
.tagline_size => if (!context.capabilities.tagline_font_size)
try out.print("{s} unsupported\n", .{setting.word})
- else if (!setting.enabled(context.capabilities))
- try out.print("{s} {d} (build-time only)\n", .{ setting.word, state.font.tagline_percent })
else
try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }),
- .transition => {
- if (wrote_transition) continue;
- wrote_transition = true;
- if (!setting.enabled(context.capabilities))
- try out.writeAll("Panel transition unsupported\n")
- else
- try out.print("Panel transition {s}\n", .{transitionSettingName(state.panel_transition)});
- },
+ // Each a switch of its own, the one in use on.
+ .transition => |transition| if (!setting.enabled(context.capabilities))
+ try out.print("{s} unsupported\n", .{setting.word})
+ else
+ try out.print("{s} {s}\n", .{ setting.word, onOff(state.panel_transition == transition) }),
.scene => |effect| {
if (!setting.enabled(context.capabilities)) {
try out.print("{s} unsupported\n", .{setting.word});
@@ -1426,7 +1384,21 @@ pub const Runtime = struct {
.inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
.grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }),
};
+ if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations});
+ // In effect, and set by no word.
+ try out.writeByte('\n');
+ try out.print("Last shell spawned: {s}\n", .{shown(state.shell.effective.get())});
+ try out.print("Shell change pending: {s}\n", .{onOff(state.shell.pending)});
+ if (find("Font").?.enabled(context.capabilities)) {
+ const f = &state.font;
+ try out.print("Font in use: {s} {d}.{d:0>2} {s}\n", .{ shown(f.effective_name.get()), f.effective_size_hundredths / 100, f.effective_size_hundredths % 100, @tagName(f.effective_size_unit) });
+ try out.print("Font change pending: {s}\n", .{onOff(f.pending)});
+ }
+ if (find("WindowOpacity").?.enabled(context.capabilities))
+ try out.print("Window opacity change pending: {s}\n", .{onOff(state.window_opacity_pending)});
+ if (context.capabilities.tagline_font_size and !find("TaglineSize").?.enabled(context.capabilities))
+ try out.writeAll("Tagline size: build-time only\n");
if (context.startup_config_path) |path|
try out.print("Startup config: {s}\n", .{path})
else
@@ -1726,9 +1698,9 @@ pub const Runtime = struct {
.chip_shadow => "ChipShadow on\n",
.thumb_flash => "ThumbFlash on\n",
},
- .shell => "Shell requested (new panes) fish\n",
+ .shell => "Shell fish\n",
.tagline_size => "TaglineSize 73\n",
- .window_opacity => "WindowOpacity 85 (pending)\n",
+ .window_opacity => "WindowOpacity 85\n",
.window_blur => "WindowBlur 40\n",
.dump_dir => "DumpDir ~/dumps\n",
.message_ms => |which| switch (which) {
@@ -1738,17 +1710,17 @@ pub const Runtime = struct {
},
.transition => |transition| switch (transition) {
.off => unreachable,
- .slide => "Panel transition PanelSlide\n",
- .zoom => "Panel transition PanelZoom\n",
- .dissolve => "Panel transition PanelDissolve\n",
- .ascii => "Panel transition PanelAscii\n",
- .vertical => "Panel transition PanelVertical\n",
- .edges => "Panel transition PanelEdges\n",
- .fall => "Panel transition PanelFall\n",
- .wave => "Panel transition PanelWave\n",
- .curtain => "Panel transition PanelCurtain\n",
- .scramble => "Panel transition PanelScramble\n",
- .typewriter => "Panel transition PanelType\n",
+ .slide => "PanelSlide on\n",
+ .zoom => "PanelZoom on\n",
+ .dissolve => "PanelDissolve on\n",
+ .ascii => "PanelAscii on\n",
+ .vertical => "PanelVertical on\n",
+ .edges => "PanelEdges on\n",
+ .fall => "PanelFall on\n",
+ .wave => "PanelWave on\n",
+ .curtain => "PanelCurtain on\n",
+ .scramble => "PanelScramble on\n",
+ .typewriter => "PanelType on\n",
},
.scene => |effect| switch (effect) {
.crt => "Crt 2\n",
@@ -1788,12 +1760,10 @@ pub const Runtime = struct {
for ([_][]const u8{
"Theme acme\n",
"WindowOpacity 85\n",
- "Font requested size 18.50\n",
- "Font requested Wanted Mono\n",
- "Font requested path /fonts/wanted.ttf\n",
- "Font effective Effective Mono\n",
- "Font pending on\n",
- "Font effective size 13.75 points\n",
+ "Font /fonts/wanted.ttf:18.50\n",
+ "Font in use: Effective Mono 13.75 points\n",
+ "Font change pending: on\n",
+ "Window opacity change pending: off\n",
"TaglineSize 82\n",
"Startup config: /tmp/pardes/init\n",
"Platform: gui\n",
@@ -1823,19 +1793,23 @@ pub const Runtime = struct {
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur unsupported\n") != null);
try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities));
try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities));
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes) /bin/sh (default)\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell /bin/sh\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Last shell spawned: (none)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell change pending: on\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null);
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font in use") == null);
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "PanelSlide off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null);
- // A shell that does not shape text has no Ligatures setting to report.
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures") == null);
+ // A setting this shell does not have reads so, as a line to write.
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures unsupported\n") != null);
+ // Every line before the blank one is a ctl word and its value.
+ const words = defaults_report[0..std.mem.indexOf(u8, defaults_report, "\n\n").?];
+ var lines = std.mem.splitScalar(u8, words, '\n');
+ while (lines.next()) |line| try std.testing.expect(find(line[0..std.mem.indexOfScalar(u8, line, ' ').?]) != null);
defaults_context.platform = "web";
defaults_context.capabilities.panel_transitions = false;
@@ -1843,8 +1817,9 @@ pub const Runtime = struct {
out = .fixed(&storage);
try writeReport(&out, defaults_context);
const web_report = storage[0..out.end];
- try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition unsupported\n") != null);
- try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100 (build-time only)\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "PanelSlide unsupported\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, web_report, "Tagline size: build-time only\n") != null);
}
};
diff --git a/src/dump.zig b/src/dump.zig
index 2d3c2593..1cc115be 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -852,6 +852,9 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
errdefer p.deinit();
if (previous) |old| {
p.next_serial = old.next_serial;
+ // Columns as panes: a restored one takes a fresh serial, never one
+ // a column of the old session had, living or deleted.
+ p.next_column_serial = old.next_column_serial;
p.lsp_seq = old.lsp_seq;
p.pipe.seq = old.pipe.seq;
p.cell_pixels = old.cell_pixels;
diff --git a/src/exec.zig b/src/exec.zig
index 8c062f66..eed9297a 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -346,7 +346,8 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
}
fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
- if (p.panes[from] == null) return null;
+ // No pane asking (an empty window's tag): the session's directory, as
+ // New's scratch is.
// A directory that is not there makes no terminal: its shell could not
// start in it, and a dead pane would answer no run. Asked only of a host
// that starts shells (none, as in the core's tests, starts none).
@@ -827,6 +828,11 @@ pub const acme_words = [_]struct { acme: []const u8, pardes: ?[]const u8, hint:
.{ .acme = "Load", .pardes = null, .hint = ": Restore loads a dump" },
.{ .acme = "ID", .pardes = null },
.{ .acme = "Send", .pardes = null },
+ .{ .acme = "Tab", .pardes = null },
+ .{ .acme = "Indent", .pardes = null },
+ .{ .acme = "Local", .pardes = null },
+ .{ .acme = "Incl", .pardes = null },
+ .{ .acme = "Abort", .pardes = null },
};
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
diff --git a/src/fs.zig b/src/fs.zig
index 1d0bbc22..30ad9c43 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1335,11 +1335,20 @@ pub const Namespace = struct {
/// 0 the workspace's, a column's its index + 1; and whether the newline
/// that ended its last write is held back.
header_rewrite: ?u32 = null,
+ /// A column's or the workspace's tag truncated, not yet done (as a
+ /// pane tag's `tag_trunc_pending`): its key, the workspace's 0.
+ header_trunc_pending: ?u32 = null,
/// A look or exec a 9P write made found every pane slot taken; the
/// write fails with what was said, kept at the head of `ename`.
no_pane_slot: bool = false,
no_pane_slot_len: u16 = 0,
header_held: bool = false,
+ /// The newest failure said (reportFailure), fitted as an err is: its
+ /// path cut in the middle, never its reason, which the message row's
+ /// first 256 bytes lose. `said_at` is the `failures` count it was.
+ said: [256]u8 = undefined,
+ said_len: u16 = 0,
+ said_at: u32 = 0,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
/// A 9P write asked a language server something: the answer count it
diff --git a/src/look.zig b/src/look.zig
index 65d001a9..36eae75c 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -583,8 +583,9 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
if (pat.len == 0) return 0;
const arena = p.scratch.allocator();
// where the pane lives: a file's directory, a shell's cwd — the walk
- // root, and the directory the results buffer is named in.
- const dir = Pardes.paneDir(pane);
+ // root, and the directory the results buffer is named in. From a
+ // column's or the workspace's tag, the session's.
+ const dir = if (p.exec_header) p.sessionDir() else Pardes.paneDir(pane);
const out = try arena.alloc(u8, filesystem.search_max_output_bytes);
var out_len: usize = 0;
var nrows: usize = 0;
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 43f94f95..33e95570 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -31,8 +31,34 @@ pub const Req = struct {
offset: u32,
arg: []const u8 = "",
root: []const u8 = "",
+ /// For `explain`: the query it narrates, read from `arg` (`<kind>
+ /// <arg>`) by `explaining`; a definition when none was asked before.
+ explains: Kind = .definition,
};
+/// An `explain` request made the one it narrates: `explains` and `arg` set
+/// from its `<kind> <arg>`; any other request as it is.
+pub fn explaining(req: Req) Req {
+ if (req.kind != .explain) return req;
+ var r = req;
+ const sp = std.mem.indexOfScalar(u8, req.arg, ' ') orelse req.arg.len;
+ const named = std.meta.stringToEnum(Kind, req.arg[0..sp]) orelse .definition;
+ r.explains = if (named == .explain or named == .status) .definition else named;
+ r.arg = if (sp < req.arg.len) req.arg[sp + 1 ..] else "";
+ return r;
+}
+
+test "an explain request narrates the query named in its arg, a definition by default" {
+ const base: Req = .{ .kind = .explain, .path = "/x.zig", .source = "", .offset = 0 };
+ var r = base;
+ r.arg = "hover ";
+ try std.testing.expectEqual(Kind.hover, explaining(r).explains);
+ r.arg = "rename newName";
+ try std.testing.expectEqual(Kind.rename, explaining(r).explains);
+ try std.testing.expectEqualStrings("newName", explaining(r).arg);
+ try std.testing.expectEqual(Kind.definition, explaining(base).explains);
+}
+
/// Every language, Zig's (zls) included, through the protocol client, which
/// runs its server as a child process. None on the web or the board.
const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4)
diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig
index 7704d0ad..0c5e3b7e 100644
--- a/src/lsp/lsp_client.zig
+++ b/src/lsp/lsp_client.zig
@@ -405,8 +405,9 @@ const Trace = struct {
// ------------------------------------------------------------------- query
-pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, asked: lsp.Req, out: *std.Io.Writer) !void {
_ = gpa;
+ const req = lsp.explaining(asked);
if (req.kind == .status) return status(req, out);
var tr: Trace = .{ .on = req.kind == .explain };
@@ -443,8 +444,8 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out
fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void {
if (req.kind != .explain) return;
- try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{
- req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
+ try out.print("lsp explain — the {s} query at byte {d} of {s}\n\n", .{
+ @tagName(req.explains), req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path),
});
try out.writeAll(tr.buf[0..tr.len]);
if (hideTime())
@@ -488,9 +489,10 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer
}
fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void {
- // `explain` narrates the definition query — dispatch below on the
- // effective kind so the trace follows the code `gd` really runs.
- const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind;
+ // `explain` narrates the last query asked (a definition, `gd`, when
+ // none was) — dispatch below on the effective kind so the trace
+ // follows the code that query really runs.
+ const kind: lsp.Kind = if (req.kind == .explain) req.explains else req.kind;
// Both arg-taking kinds are useless without one, and an empty
// workspace/symbol query means "every symbol in the project".
diff --git a/src/main.zig b/src/main.zig
index 0e2ba954..6bdf2bfd 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -416,7 +416,15 @@ fn nativeMain(init: std.process.Init) !void {
// creates the file from.
const base = std.fs.path.basename(target.path);
if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding;
- const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding;
+ const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse {
+ // Its directory not there either: the name made absolute as
+ // written, and the pane's Save makes the directories (a
+ // name into a missing one says so, pane.zig writeName).
+ var cwd_buf: [4096]u8 = undefined;
+ if (std.c.getcwd(&cwd_buf, cwd_buf.len) == null) break :forwarding;
+ const cwd = std.mem.sliceTo(&cwd_buf, 0);
+ break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch break :forwarding;
+ };
break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
};
if (found_path != null) {
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
diff --git a/src/pardes.zig b/src/pardes.zig
index a63568db..f0d22d57 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1112,6 +1112,46 @@ test "first document fallback splits like Tty from the originating pane" {
};
}
+test "Lspwhy after a Hover asks to explain that hover, at its place" {
+ if (!lsp.supports.contains(.hover)) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("const a = 1;\nconst b = a;\n");
+ const f = &pane.file.?;
+ p.gpa.free(f.path);
+ f.path = try p.gpa.dupe(u8, "/tmp/x.zig");
+ while (p.nextEffect()) |_| {}
+ pane.body.cur_row = 1;
+ pane.body.cur_col = 10;
+ p.lspRequest(0, .hover, "");
+ var hover_at: u32 = 0;
+ while (p.nextEffect()) |e| if (e == .lsp) {
+ hover_at = e.lsp.offset;
+ };
+ pane.body.cur_row = 0;
+ pane.body.cur_col = 0;
+ p.lsp_wait = null;
+ p.lspRequest(0, .explain, "");
+ var seen = false;
+ while (p.nextEffect()) |e| if (e == .lsp) {
+ try std.testing.expectEqual(lsp.Kind.explain, e.lsp.kind);
+ try std.testing.expectEqualStrings("hover ", e.lsp.arg.slice());
+ try std.testing.expectEqual(hover_at, e.lsp.offset);
+ seen = true;
+ };
+ try std.testing.expect(seen);
+}
+
+test "the Tutor pane is named +Tutor in the session's directory, never a bare path at the root" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
+ const tutor = try p.openTutorView(1);
+ try std.testing.expectEqualStrings("/project/src/+Tutor", tutor.file.?.path);
+}
+
test "first document keeps its originating shell and only opens a column when it fits" {
for ([_]u16{ 199, 200 }) |width| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 });
@@ -3543,7 +3583,9 @@ pub const Effect = union(enum) {
write_dump,
set_clipboard,
read_clipboard,
- lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) },
+ /// `arg` up to 128 bytes, or for `explain` the query it explains,
+ /// `<kind> <arg>`.
+ lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(160) },
/// Snapshot the matching request with pipeRequest(id), then run it away
/// from the UI/event loop and answer with pipe_resp.
pipe: struct { id: u32 },
@@ -4341,6 +4383,9 @@ pub const Pardes = struct {
can_ask: bool = false,
lsp_seq: u32 = 0,
+ /// The last query asked (Lspwhy explains it): its kind, argument, and
+ /// the pane and place it was asked at.
+ lsp_last: ?struct { kind: lsp.Kind, arg: Effect.Buf(128), serial: u32, offset: u32 } = null,
lsp_wait: ?struct {
id: u32,
kind: lsp.Kind,
@@ -5046,11 +5091,14 @@ pub const Pardes = struct {
// is this process's directory, a rename to undo. That answer, then,
// is not taken.
if (pane.isTerminal() and !pane.shell_spoke and pane.cwd == .owned and std.mem.eql(u8, cwd, p.sessionDir())) return;
+ // One with no directory yet is named by the session's (nameOf): its
+ // shell saying it is there is no new name.
+ const same_name = cur.len == 0 and std.mem.eql(u8, cwd, p.sessionDir());
pane.setOwnedCwd(cwd) catch |err| return p.reportError(id, "directory", err);
if (look.lookHoverPane(p) == id) look.cancelLookHover(p);
// A terminal is named by its directory, so /log hears it renamed; one
// not announced yet is announced under the new name.
- if (pane.isTerminal() and !pane.fs.unannounced) ctlfs.events.noteLog(p, .rename, pane);
+ if (pane.isTerminal() and !same_name) ctlfs.events.noteLog(p, .rename, pane);
}
pub fn shellBin(p: *const Pardes) []const u8 {
@@ -5338,10 +5386,7 @@ pub const Pardes = struct {
}
// A 9P write that asked for the shell (pty/ctl's exec), waiting on
// it, fails with what was said, as a failed Save's does.
- const said = pane.msg[0..pane.msg_len];
- const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
- @memcpy(p.fs.late_failure[0..kept.len], kept);
- p.fs.late_failure_len = @intCast(kept.len);
+ p.noteLateFailure(id);
}
pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
@@ -5359,10 +5404,7 @@ pub const Pardes = struct {
p.reportFailure(id, std.fmt.bufPrint(&what, "Save {s}: no such directory", .{path}) catch "Save: no such directory")
else
p.reportError(id, std.fmt.bufPrint(&what, "Save {s}", .{path}) catch "Save", err);
- const said = if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else "Save failed";
- const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
- @memcpy(p.fs.late_failure[0..kept.len], kept);
- p.fs.late_failure_len = @intCast(kept.len);
+ p.noteLateFailure(id);
}
/// A Dump the host could not write: said on the message row and in the
@@ -5385,10 +5427,7 @@ pub const Pardes = struct {
} else p.reportError(p.active, operation, err);
// In the words the message row has, so the write's err is the one
// record of it (its msg goes: 9p_io, dropMessage).
- const said = if (p.panes[p.active]) |pane| pane.msg[0..pane.msg_len] else "Dump failed";
- const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
- @memcpy(p.fs.late_failure[0..kept.len], kept);
- p.fs.late_failure_len = @intCast(kept.len);
+ p.noteLateFailure(p.active);
}
/// The path a watch is about: a real file's, or a PDF's.
@@ -6291,6 +6330,17 @@ pub const Pardes = struct {
.row = @intCast(@max(0, pane.body.cur_row)),
.col = @intCast(@max(0, pane.body.cur_col)),
}) else 0;
+ // Lspwhy asks the last query again, narrated: its kind and argument,
+ // at its place when asked from its pane. None yet: a definition.
+ var explained: [160]u8 = undefined;
+ var sent = arg;
+ var at = off;
+ if (kind == .explain) {
+ if (p.lsp_last) |last| {
+ sent = std.fmt.bufPrint(&explained, "{s} {s}", .{ @tagName(last.kind), last.arg.slice() }) catch "";
+ if (last.serial == pane.serial) at = last.offset;
+ }
+ } else if (kind != .status) p.lsp_last = .{ .kind = kind, .arg = .from(arg), .serial = pane.serial, .offset = @intCast(off) };
p.lsp_seq +%= 1;
// A 9P write that asked it is answered once the answer is in.
if (p.fs.serving) p.fs.lsp_answer_at = turn.lsp_answers;
@@ -6308,8 +6358,8 @@ pub const Pardes = struct {
.id = p.lsp_seq,
.kind = kind,
.pane = @intCast(id),
- .offset = @intCast(off),
- .arg = .from(arg),
+ .offset = @intCast(at),
+ .arg = .from(sent),
} });
}
@@ -6457,8 +6507,11 @@ pub const Pardes = struct {
/// What was said of a failure just now on pane `id` is also the waiting
/// 9P write's (9p_io, `fs.late_failure`).
pub fn noteLateFailure(p: *Pardes, id: usize) void {
- const pane = p.panes[id] orelse return;
- const said = pane.msg[0..pane.msg_len];
+ // Said by reportFailure just now: its words fitted whole, the
+ // reason kept; else what the message row has.
+ const said = if (p.fs.said_at == p.fs.failures and p.fs.said_len > 0)
+ p.fs.said[0..p.fs.said_len]
+ else if (p.panes[id]) |pane| pane.msg[0..pane.msg_len] else return;
const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
@memcpy(p.fs.late_failure[0..kept.len], kept);
p.fs.late_failure_len = @intCast(kept.len);
@@ -6576,7 +6629,9 @@ pub const Pardes = struct {
pub fn openTutorView(p: *Pardes, id: usize) !*Pane {
const content = try p.gpa.dupe(u8, tutor_text);
errdefer p.gpa.free(content);
- const path = try p.gpa.dupe(u8, "/Tutor");
+ // A generated pane, named as every one is: `+` in the session's
+ // directory, never a bare path Save could write at the root.
+ const path = try std.fs.path.join(p.gpa, &.{ p.sessionDir(), "+Tutor" });
errdefer p.gpa.free(path);
const history = try panes.File.History.create(p.gpa);
errdefer p.gpa.destroy(history);
diff --git a/src/regexp.zig b/src/regexp.zig
index ddb5eb28..575e6861 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -56,6 +56,7 @@ pub const Regex = struct {
pub const e_anchor = "bad regular expression: in a pattern with \\n, ^ can only come first and $ only just before a \\n";
pub const e_wide = std.fmt.comptimePrint("bad regular expression: a range of runes wider than {d} in [...] is not supported", .{max_range});
pub const e_negated = "bad regular expression: a [^...] with non-ASCII runes is not supported (mvzr's classes hold bytes)";
+ pub const e_mixed = "bad regular expression: an alternation anchors every branch with ^ or none (^a|^b, not ^a|b)";
/// The most runes a non-ASCII range in a class is spelled out as.
pub const max_range = 256;
@@ -79,7 +80,7 @@ pub const Regex = struct {
return @intCast(n);
}
- pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes };
+ pub const Error = error{ Bad, Anchor, TooLong, WideRange, NegatedRunes, Mixed };
pub fn compile(pat: []const u8) Error!Regex {
if (pat.len == 0) return error.Bad;
@@ -273,7 +274,7 @@ pub const Regex = struct {
/// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at
/// its top level and every branch starts with `^`; null when it is not
/// one, or no branch does.
- fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{Bad}!?[]const u8 {
+ fn anchoredAlternation(pat: []const u8, out: *[5 * max_ops + 2]u8) error{ Bad, Mixed }!?[]const u8 {
var bars: [16]usize = undefined;
var n: usize = 0;
var depth: usize = 0;
@@ -306,7 +307,8 @@ pub const Regex = struct {
from = to + 1;
}
if (anchored == 0) return null;
- if (anchored != n + 1) return error.Bad;
+ // Only `^` counts: `$` is mvzr's own at a branch's end (`a$|b`).
+ if (anchored != n + 1) return error.Mixed;
var w = std.Io.Writer.fixed(out);
w.writeAll("^(") catch return error.Bad;
from = 0;
@@ -481,7 +483,7 @@ test "a ^ after | anchors that branch: ^def|^ finds a line that starts either w
var both = try Regex.compile("^def|^ ");
try std.testing.expectEqual(@as(usize, 6), (try both.find(text, 0, text.len, text.len)).?.start);
try std.testing.expectEqual(@as(usize, 9), (try both.find(text, 7, text.len, text.len)).?.start);
- try std.testing.expectError(error.Bad, Regex.compile("^def|x"));
+ try std.testing.expectError(error.Mixed, Regex.compile("^def|x"));
var plain = try Regex.compile("a|b");
try std.testing.expectEqual(@as(usize, 7), (try plain.find(text, 0, text.len, text.len)).?.start);
}
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index 0f317a83..94cf6fe0 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -463,6 +463,7 @@ const Exec = struct {
error.TooLong => fail(ex.why, "{s}", .{regexp.Regex.e_long}),
error.WideRange => fail(ex.why, "{s}", .{regexp.Regex.e_wide}),
error.NegatedRunes => fail(ex.why, "{s}", .{regexp.Regex.e_negated}),
+ error.Mixed => fail(ex.why, "{s}", .{regexp.Regex.e_mixed}),
error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}),
};
}
diff --git a/src/terminal.zig b/src/terminal.zig
index 6bf46bef..1a3b6a21 100644
--- a/src/terminal.zig
+++ b/src/terminal.zig
@@ -1343,7 +1343,10 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
if (pane.terminal == null) return &empty_grid;
const c = &p.shell_rows;
if (!c.stale and c.pane == pane) return c.rows;
- if (c.pane != null) {
+ // Another pane's rows, or a dropped pane's still waiting for the step's
+ // sweep (dropPane keeps them, as they may still be borrowed): this
+ // step's rows are scratch, and nothing in the slot is written over.
+ if (c.pane != null or c.text_alloc.len > 0 or c.rows.len > 0) {
c.stale = true;
return (try buildRows(p.scratch.allocator(), p, pane)).rows;
}
@@ -1357,6 +1360,18 @@ pub fn shellRows(p: *Pardes, pane: *Pane) ![]const []const u8 {
return c.rows;
}
+test "rows asked for after a pane's cached rows were dropped, before the sweep, leak nothing" {
+ if (comptime !enabled) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ try std.testing.expect(pane.terminal != null);
+ _ = try shellRows(p, pane);
+ // A pane's teardown drops its entry; the rows stay until the sweep.
+ p.shell_rows.dropPane(pane);
+ _ = try shellRows(p, pane);
+}
+
/// The full modal motion surface: shell history with the live edit overlay
/// spliced into the rows it covers.
pub fn cursorLines(p: *Pardes, pane: *Pane) ![]const []const u8 {
diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py
index c321c7c0..98420a15 100644
--- a/test/appearance_gallery.py
+++ b/test/appearance_gallery.py
@@ -98,7 +98,7 @@ def run(args):
return text
settings = config_report()
- require('Font effective size 18.00 pixels' in settings, 'configured font size not applied: ' + settings)
+ require(' 18.00 pixels\n' in settings, 'configured font size not applied: ' + settings)
require('TaglineSize 80' in settings, 'configured tag size missing')
(root / 'effective-config.txt').write_text(settings)
report['font_effective_pixels'] = 18
diff --git a/test/font_size.py b/test/font_size.py
index ef685a9e..878d6472 100644
--- a/test/font_size.py
+++ b/test/font_size.py
@@ -30,16 +30,16 @@ def run(args):
for serial, tag in panes(client).items():
if '/+DumpConfig ' in tag:
body = client.read(f'/pane/{serial}/body').decode()
- if f'Font effective size {size:.2f} pixels' in body:
+ if f' {size:.2f} pixels\n' in body and 'Font in use: ' in body:
return body
return None
return require(wait_for(ready), f'Font did not become {size} pixels')
initial = report(18)
- require('Font requested size 18.00' in initial, 'startup size suffix ignored')
+ require(':18.00\n' in initial, 'startup size suffix ignored')
require(args.font in initial, 'requested font missing')
execute(client, control, f'Font {args.font}:20.5')
- require('Font requested size 20.50' in report(20.5), 'fractional size ignored')
+ require(':20.50\n' in report(20.5), 'fractional size ignored')
execute(client, control, f'Font {args.font}')
report(20.5) # no suffix preserves current size
execute(client, control, f'Font {args.font}:900')
diff --git a/test/fs.py b/test/fs.py
index 6f1165e5..a6cd332e 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -1294,6 +1294,22 @@ def test(binary, quic=False):
client.remove(f'/pane/{named}')
assert made.wait(timeout=5) == 0
assert fresh.read_bytes() == b'new\n'
+ # ...in a directory not there either: forwarded all the same, and
+ # Save makes the directories.
+ deep = root / 'wait-missing' / 'sub' / 'new.txt'
+ before = serials()
+ made = subprocess.Popen([binary, 'wait-missing/sub/new.txt'], cwd=root, env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
+ assert made.wait(timeout=5) == 0, made.stderr.read()
+ deadline = time.monotonic() + 3
+ while str(deep).encode() not in client.read('/index'):
+ assert time.monotonic() < deadline, 'a name in a missing directory opened no pane named it'
+ time.sleep(.01)
+ named, = serials() - before
+ client.write(f'/pane/{named}/body', b'deep\n')
+ execute(client, named, 'Save')
+ client.remove(f'/pane/{named}')
+ assert deep.read_bytes() == b'deep\n'
orphan = launch('--wait')
assert orphan.poll() is None
assert orphan.wait(timeout=5) == 1
diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt
index 2d3c1a77..0d782af4 100644
--- a/test/gui-goldens.txt
+++ b/test/gui-goldens.txt
@@ -1,17 +1,17 @@
-01-boot 334054bba79ffb64553c2ed50dece720badf811eebc1f37fcbb859e90494dc56
-02-second-document 83a0b43c4bbfa47dbb35af854ce888a8f2ee2db21cf207c011ae6d4005068409
-03-notice 2c276935562be85dc807ecca7f35ef2affec1022f08efb134b7313c4866edaf1
-04-context-rows-mid-thumb f4178af9945946e2ae943dddbdf84b756dab45f2f42347bd25ca165ddf5b08e8
-05-body-selection 96a158adac982f4141526d10956e19828632edc0f6f60d1161d1353e34797118
-06-tag-focus-selection 94aff8499a6e6f399761ffdd56369d526fbe65232eb21c88addd12973af611aa
-07-prompt-notice 041603b841605b0a9456cbbb9b3e06e219b0a08a58e2d38229e1c81366577a87
-08-wide-glyph-edges 4f15245cb2e14f4190940a79c61751bbb6efbf21c1e3ab423fb1469e0466697e
-09-collapsed-focus-tint 1d0e6e4b371bc3b2bba96d9177b91f240fa4bef38c62470dc078fa9feed119ac
-10-tagbottom f7175dfb338b75c5ac3457c8422dc0e352c46d51b2c52533ce32ef143fcf6c6b
-12-multiline-tag ddd7d775a246e91b88f0eff98b6f9bea8aa5d63dbfbe931bd830ccb9086bd5c4
-13-image f609043fc0fcdb55369b836b61fcbc631f786cc2295a5ddd72c0d9a59b177a60
-14-theme-ink 881b7e99f38ad4aa3c46d2c61e20598010175d7858712a67fa6638fc29f12e14
-15-theme-acme-light 9648633a897416b14849e0dbb857a98d4c4b7d46aa0e799ab5e302fbe240652f
-16-debug be4d09d67b4293dd038bca57960d77d65d1f9f0281433e9cb9cbba654418fc15
-17-terminal 440cc30bb1f5086e572840f6ccd211dda1e266d77fe02f997e51d92f52494d3c
-18-mid-transition e1f6459cb918050e4f4e8cd485d924f022e75bc19d80c79f710117b18168e8e8
+01-boot 6b879535ac5d120a468290175f508064a11f257a0f52924fee423df11686ffb3
+02-second-document e07139723a029c1bebcd5ca19da86f3e0cc2af20abc4cf540fc551ae8d65ad2b
+03-notice 8ad2e24e5cb5eb8ef9234a6ebd14916e5780764c5f8a2a119f3a8901dc1b042e
+04-context-rows-mid-thumb 19b1a173848a1b8895c39c58107df1512bf7b314a032d525ce815ba94180266a
+05-body-selection 533db971a21519eba77effc1c5d6222257348602131d24130d8dea62a44d698b
+06-tag-focus-selection 711220bcec560f6ba6204a694827ed58c374ecce8325c5c459154e8f2319f252
+07-prompt-notice 1d02541031d04d2a0a861102648295f6cd5b30d33b6c0dd4e55f6076d7a5c005
+08-wide-glyph-edges ddfe437d9cf36063bb09cddc36bac4bcd30b29c4bcb0d6453f9706c0c222b187
+09-collapsed-focus-tint d2f3edb45e23f84e5f3c792b388c77dd70e0e8a15a5b2bd85e0917f019de1421
+10-tagbottom 6347013122c19b7ee6505d3e7138d9fa353d8b9d8213fbb847615b75b0ddb0e5
+12-multiline-tag 48d4a0c0f45a1709d8e3dea3056e1147ab29bfe3fd6980b4568333b8183828d6
+13-image ca5c7d92e688b4c4b8a1d7f7075c55acb049bd89b49488effd2de74056f92bdf
+14-theme-ink a7cdb4de4a2a6ff3ee58040ff5052f36ac60ad1ab8d132d7832059692e96fd73
+15-theme-acme-light e3af35d1e5ae57cb74af807940a76beebf510ed04a2437f4be1f4a53133af48b
+16-debug ed971e715e45fa04f0ecddefd6b0d0b1285c912d6940ca9632a7defc377bbe61
+17-terminal a09b53740c0a6fecd354c4f9b67ef0131b19aef92af55488cdead77b030d1062
+18-mid-transition 7d09ffede92fde77722cebc91d68b6f800dd1f2bfba0afb9480ab926f001b894
diff --git a/test/monkey9p.py b/test/monkey9p.py
index ee0b0ebc..7ab6cb0c 100644
--- a/test/monkey9p.py
+++ b/test/monkey9p.py
@@ -1514,7 +1514,11 @@ def one_failure_rule(ctx, res):
if not lo <= len(errs) <= hi:
return (f'{len(failed)} failed write(s) logged {len(errs)} err record(s) '
f'(allowed {lo}..{hi}); failed={[(w[0], w[1], w[3]) for w in failed]} window={res.window}')
- if failed and msgs:
+ # A msg is a failed write's only when no Twrite of the op succeeded: a
+ # line cut across Twrites runs a piece a Twrite, and a piece that ran
+ # (a Save) says so while a later one is refused.
+ succeeded = [w for w in writes if w[2] is True and w[0] == 'write']
+ if failed and msgs and not succeeded:
return f'a failed write also logged msg: {msgs}; failed={[(w[1], w[3]) for w in failed]}'
return None
diff --git a/test/output.zig b/test/output.zig
index c67c021e..51d620c0 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -544,11 +544,12 @@ test "DumpConfig prints the startup path and refreshes its one output" {
for ([_][]const u8{
"Startup config: " ++ path ++ "\n",
"Theme orchard\n",
- "Shell effective (last spawn) (none)\n",
- "Shell pending on\n",
+ "Last shell spawned: (none)\n",
+ "Shell change pending: on\n",
+ "LocationsConfig ",
}) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null);
var default_line: [256]u8 = undefined;
- try std.testing.expect(std.mem.indexOf(u8, out.content, try std.fmt.bufPrint(&default_line, "Shell requested (new panes) {s} (default)\n", .{config.defaultShell()})) != null);
+ try std.testing.expect(std.mem.indexOf(u8, out.content, try std.fmt.bufPrint(&default_line, "Shell {s}\n", .{config.defaultShell()})) != null);
try std.testing.expectEqualStrings(config.dump_config_buffer, std.fs.path.basename(out.path));
try std.testing.expectEqual(panes.Output.Origin{ .cmd = .DumpConfig }, out.output.?.from);
@@ -734,14 +735,14 @@ test "every enabled setting builtin mutates the State Config reports" {
"Tagbottom on\n",
"Debug on\n",
"Theme acme\n",
- "Shell requested (new panes) fish\n",
+ "Shell fish\n",
}) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null);
- const transition = if (builtins.capabilities.panel_transitions)
- try std.fmt.bufPrint(&buf, "Panel transition {s}\n", .{
- config.Runtime.findAction(.{ .transition = p.settings.panel_transition }).?.word,
- })
+ const transition = if (!builtins.capabilities.panel_transitions)
+ "PanelSlide unsupported\n"
+ else if (p.settings.panel_transition == .off)
+ "PanelSlide off\n"
else
- "Panel transition unsupported\n";
+ try std.fmt.bufPrint(&buf, "{s} on\n", .{config.Runtime.findAction(.{ .transition = p.settings.panel_transition }).?.word});
try std.testing.expect(std.mem.indexOf(u8, report, transition) != null);
// A bare word turns a bundled pass on at its default level.
const scene_status = if (builtins.capabilities.scene_shaders) "2" else "unsupported";
@@ -752,14 +753,11 @@ test "every enabled setting builtin mutates the State Config reports" {
const tagline = if (!builtins.capabilities.tagline_font_size)
"TaglineSize unsupported\n"
else
- try std.fmt.bufPrint(&buf, "TaglineSize {d}{s}\n", .{
- p.settings.font.tagline_percent,
- if (builtins.capabilities.font_picker) "" else " (build-time only)",
- });
+ try std.fmt.bufPrint(&buf, "TaglineSize {d}\n", .{p.settings.font.tagline_percent});
try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null);
if (chosen_font) |name| {
try std.testing.expect(std.mem.indexOf(u8, report, name) != null);
- try std.testing.expect(std.mem.indexOf(u8, report, "Font pending on\n") != null);
+ try std.testing.expect(std.mem.indexOf(u8, report, "Font change pending: on\n") != null);
}
}
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
index 55bb2b70..8808dd55 100644
--- a/test/perf-baseline-gui-Debug.json
+++ b/test/perf-baseline-gui-Debug.json
@@ -1 +1 @@
-{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1970,"med_us":2159,"p90_us":2428,"max_us":2940},{"op":"render","fixture":"medium","min_us":75,"med_us":76,"p90_us":78,"max_us":79},{"op":"key-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":83,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":78,"p90_us":95,"max_us":107},{"op":"page-down","fixture":"medium","min_us":75,"med_us":78,"p90_us":81,"max_us":92},{"op":"wheel","fixture":"medium","min_us":75,"med_us":79,"p90_us":82,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1056,"med_us":1524,"p90_us":1648,"max_us":1751},{"op":"body-2m","fixture":"medium","min_us":8421,"med_us":15590,"p90_us":19804,"max_us":21613}]}
+{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1740,"med_us":2009,"p90_us":2077,"max_us":2079},{"op":"render","fixture":"medium","min_us":76,"med_us":80,"p90_us":90,"max_us":101},{"op":"key-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":94,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":82,"p90_us":86,"max_us":89},{"op":"page-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":86,"max_us":99},{"op":"wheel","fixture":"medium","min_us":76,"med_us":76,"p90_us":80,"max_us":89},{"op":"edit-char","fixture":"medium","min_us":924,"med_us":1330,"p90_us":1522,"max_us":1566},{"op":"body-2m","fixture":"medium","min_us":4888,"med_us":8435,"p90_us":10635,"max_us":11460},{"op":"body-appends","fixture":"medium","min_us":119102,"med_us":187377,"p90_us":230392,"max_us":245721}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
index c4361c57..ef42f0ad 100644
--- a/test/perf-baseline-tty-Debug.json
+++ b/test/perf-baseline-tty-Debug.json
@@ -1 +1 @@
-{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1831,"med_us":2060,"p90_us":2194,"max_us":2252},{"op":"render","fixture":"medium","min_us":75,"med_us":79,"p90_us":79,"max_us":87},{"op":"key-down","fixture":"medium","min_us":77,"med_us":80,"p90_us":86,"max_us":91},{"op":"key-right","fixture":"medium","min_us":77,"med_us":81,"p90_us":89,"max_us":92},{"op":"page-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":85,"max_us":86},{"op":"wheel","fixture":"medium","min_us":75,"med_us":77,"p90_us":83,"max_us":93},{"op":"edit-char","fixture":"medium","min_us":1071,"med_us":1446,"p90_us":1889,"max_us":2537},{"op":"body-2m","fixture":"medium","min_us":8740,"med_us":15875,"p90_us":19640,"max_us":21463}]}
+{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1743,"med_us":2011,"p90_us":2152,"max_us":2217},{"op":"render","fixture":"medium","min_us":76,"med_us":79,"p90_us":80,"max_us":84},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":85,"max_us":89},{"op":"key-right","fixture":"medium","min_us":77,"med_us":80,"p90_us":83,"max_us":95},{"op":"page-down","fixture":"medium","min_us":78,"med_us":78,"p90_us":87,"max_us":95},{"op":"wheel","fixture":"medium","min_us":76,"med_us":80,"p90_us":88,"max_us":90},{"op":"edit-char","fixture":"medium","min_us":1122,"med_us":1501,"p90_us":1812,"max_us":2440},{"op":"body-2m","fixture":"medium","min_us":4949,"med_us":8504,"p90_us":10909,"max_us":11452},{"op":"body-appends","fixture":"medium","min_us":115605,"med_us":190785,"p90_us":226244,"max_us":245258}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
index 96ea5801..66421ba3 100644
--- a/test/perf-baseline-tty-ReleaseFast.json
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -1 +1 @@
-{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1760,"med_us":2041,"p90_us":2185,"max_us":2206},{"op":"render","fixture":"medium","min_us":76,"med_us":85,"p90_us":100,"max_us":102},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":85,"max_us":97},{"op":"key-right","fixture":"medium","min_us":76,"med_us":77,"p90_us":90,"max_us":92},{"op":"page-down","fixture":"medium","min_us":77,"med_us":80,"p90_us":85,"max_us":87},{"op":"wheel","fixture":"medium","min_us":75,"med_us":80,"p90_us":89,"max_us":97},{"op":"edit-char","fixture":"medium","min_us":1146,"med_us":1473,"p90_us":1659,"max_us":1796},{"op":"body-2m","fixture":"medium","min_us":8487,"med_us":15600,"p90_us":20312,"max_us":21859}]}
+{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1751,"med_us":2134,"p90_us":2183,"max_us":2415},{"op":"render","fixture":"medium","min_us":76,"med_us":77,"p90_us":91,"max_us":92},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":82,"max_us":83},{"op":"key-right","fixture":"medium","min_us":79,"med_us":85,"p90_us":100,"max_us":102},{"op":"page-down","fixture":"medium","min_us":78,"med_us":82,"p90_us":86,"max_us":95},{"op":"wheel","fixture":"medium","min_us":76,"med_us":79,"p90_us":83,"max_us":87},{"op":"edit-char","fixture":"medium","min_us":1063,"med_us":1290,"p90_us":1527,"max_us":1564},{"op":"body-2m","fixture":"medium","min_us":4950,"med_us":9414,"p90_us":10945,"max_us":13152},{"op":"body-appends","fixture":"medium","min_us":117839,"med_us":189253,"p90_us":232943,"max_us":247720}]}
diff --git a/test/perf.zig b/test/perf.zig
index fca4a3b0..95b55748 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -49,6 +49,10 @@ const Op = enum {
/// 2 MB appended to the file through 9P, on one open, in 256 KB
/// writes: linear, not a copy of the whole buffer per write.
body_write,
+ /// 64 appends to the file through 9P, each its own open, 8 KB write and
+ /// close, as a shell's `>>` loop makes: each costs its own bytes, not a
+ /// pass over the whole body for its cursor, line index and saved hash.
+ body_appends,
fn label(o: Op) []const u8 {
return switch (o) {
@@ -60,6 +64,7 @@ const Op = enum {
.wheel => "wheel",
.edit_char => "edit-char",
.body_write => "body-2m",
+ .body_appends => "body-appends",
};
}
};
@@ -78,6 +83,20 @@ fn bodyWrite(core: *pardes.Pardes, serial: u32) void {
_ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true });
}
+const append_chunk_len = 8 * 1024;
+const append_count = 64;
+
+fn bodyAppends(core: *pardes.Pardes, serial: u32) void {
+ const Node = pardes.ctlfs.Node;
+ const body = Node.of(serial, .body);
+ for (body_chunk[0..append_chunk_len], 0..) |*c, i| c.* = if (i % 64 == 63) '\n' else 'y';
+ for (0..append_count) |_| {
+ const h = core.serveFs(.{ .tag = 1, .op = .open, .node = body, .omode = 1 }).handle;
+ _ = core.serveFs(.{ .tag = 2, .op = .write, .node = body, .handle = h, .data = body_chunk[0..append_chunk_len] });
+ _ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true });
+ }
+}
+
const TermFixture = struct {
name: []const u8,
kb: usize,
@@ -533,6 +552,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
.wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }),
.edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
.body_write => bodyWrite(core, pane.serial),
+ .body_appends => bodyAppends(core, pane.serial),
}
pump(core);
_ = try frame(core);
@@ -540,6 +560,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
switch (op) {
.open => unreachable,
.body_write => if (pane.file.?.content.len != before.len + 8 * body_chunk.len) return error.BodyWriteWasNotAppended,
+ .body_appends => if (pane.file.?.content.len != before.len + append_count * append_chunk_len) return error.BodyAppendsWereNotAppended,
.render => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col or pane.file.?.content.len != before.len)
return error.RenderChangedDocument,
.key_down => if (pane.body.cur_row != before.row + 1) return error.DownDidNotMove,
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index 6b79d47f..0f13ed7c 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -27,7 +27,7 @@
|16:
== snap tutor-from-index grid=120x67 cursor=7,16
|10: 1 pardes builtins under SPC h Tutor
-|15: /Tutor Save Tty Collapse Del
+|15: /tmp/pardes-snap/builtins/cwd/+Tutor Save Tty Collapse Del
|16: 1
|17: 2 PARDES TUTOR
|18: 3 tmux + vi + acme, from scratch.
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 52528cde..bde9f7bf 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -18,7 +18,7 @@
|11: 6
== snap tutor grid=100x41 cursor=7,12
|6: 1 pardes builtins under SPC h Tutor
-|11: /Tutor Save Tty Collapse Del
+|11: /tmp/pardes-snap/leader/cwd/+Tutor Save Tty Collapse Del
|12: 1
|13: 2 PARDES TUTOR
|14: 3 tmux + vi + acme, from scratch.
diff --git a/test/snapshots/tutor.golden b/test/snapshots/tutor.golden
index 1be72b87..2976f99a 100644
--- a/test/snapshots/tutor.golden
+++ b/test/snapshots/tutor.golden
@@ -4,7 +4,7 @@
| /tmp/pardes-snap/tutor/cwd Tty+bash Save Mode Filter Collapse Del /tmp/pardes-snap/tutor/cwd Tty+bash Save Mode Filter Collapse Del
| ls Tutor ls
|
-| /Tutor Save Tty Collapse Del
+| /tmp/pardes-snap/tutor/cwd/+Tutor Save Tty Collapse Del
| 1
| 2 PARDES TUTOR
| 3 tmux + vi + acme, from scratch.