diff options
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)). @@ -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 { @@ -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') @@ -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. |
