diff options
41 files changed, 1188 insertions, 121 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index a91b4861..16e90dc9 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -289,8 +289,8 @@ through 14:2 inclusive), whose regexps are mvzr's searched as sam searches: newline, the leftmost match wins (the first alternative there, not the longest). In a pattern with `\n`, `^` works only first (`^def .*\n` finds every def line) and `$` only just before a `\n`; anywhere else the pattern is -refused, not silently unmatched. `^` inside an alternation (`^def|^ `) holds -only where the search starts: fine in `x` over lines or `g`, not mid-line. An expression is evaluated from the current address (the last one +refused, not silently unmatched. `^def|^ ` finds lines starting either way +(every branch anchored; a mix like `^def|x` is refused). An expression is evaluated from the current address (the last one written, or just past the last `data` write): `.` is that address, not the selection, `/re/` searches on from its end and wraps unless `limit` is set, `?re?` or `-/re/` searches back, `#100,#50` fails `addresses out of order`, @@ -682,11 +682,195 @@ pub fn build(b: *std.Build) void { .tables_path = uucode_tables, }).module("uucode"); - const vaxis_mod = b.dependency("vaxis", .{ + const vaxis_dep = b.dependency("vaxis", .{ .target = target, .optimize = optimize, .external_uucode = true, - }).module("vaxis"); + }); + const vaxis_mod = vaxis_dep.module("vaxis"); + // vaxis's input parser, patched here at build time like mvzr so that a + // fresh fetch keeps it: no reply, split read or malformed sequence may + // reach the editor as typed text, and none may panic or end input. + // - A cursor position report that no F3 could have sent is no key: the + // startup width queries' `CSI 1;1R` arrived as two F3s, which a shell + // pane took for text. + // - ESC before a CSI or SS3 key (rxvt's Alt+arrow) is Alt on that key, + // not Alt+Escape followed by `[A` as text. + // - An X10 mouse report and a kitty graphics reply cut short by a read + // wait for their rest: the first became three bytes of text, the + // second sliced past its input. + // - A malformed color or clipboard reply is dropped instead of failing + // the parse, which stopped input; so is a byte no UTF-8 starts with. + // - An OSC reply ends at its first terminator: one ended by BEL ran on to + // the next sequence's ESC and left what lay between as text. + // - A device report's fields are sought in the sequence, not past it. + // A bump that moves an anchor stops the build. + { + const parser_path = vaxis_dep.path("src/Parser.zig").getPath(b); + var src = std.Io.Dir.cwd().readFileAlloc(io, parser_path, b.allocator, .limited(1 << 20)) catch @panic("read vaxis Parser.zig"); + const edits = [_]struct { anchor: []const u8, with: []const u8, count: usize = 1 }{ + .{ + .anchor = + \\ 0x5F => return parseApc(input), + \\ else => { + , + .with = + \\ 0x5F => return parseApc(input), + \\ // pardes's patch (its build.zig): ESC before a key's sequence is Alt on the key. + \\ 0x1B => if (input.len > 2 and (input[2] == '[' or input[2] == 'O')) { + \\ var inner = if (input[2] == '[') parseCsi(input[1..], &self.buf) else parseSs3(input[1..]); + \\ if (inner.n == 0) return inner; + \\ if (inner.event) |*event| switch (event.*) { + \\ .key_press, .key_release => |*key| key.mods.alt = true, + \\ else => {}, + \\ }; + \\ inner.n += 1; + \\ return inner; + \\ } else return .{ .event = .{ .key_press = .{ .codepoint = Key.escape, .mods = .{ .alt = true } } }, .n = 2 }, + \\ else => { + , + }, + .{ + .anchor = " const first_cp = iter.next() orelse return error.InvalidUTF8;\n", + .with = " const first_cp = iter.next() orelse return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return error.InvalidUTF8;\n", + .with = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + .with = + \\ // pardes's patch (its build.zig): an ESC that ends the input is half an ST. + \\ if (input.len < end + 2) return .{ .event = null, .n = 0 }; + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + }, + .{ + .anchor = + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ + \\ // No escape, could be BEL terminated + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07) orelse return .{ + \\ .event = null, + \\ .n = 0, + \\ }; + \\ bel_terminated = true; + \\ break :blk bel + 1; + , + .with = + \\ // pardes's patch (its build.zig): whichever terminator comes first ends it. + \\ const esc = std.mem.indexOfScalarPos(u8, input, 2, 0x1b); + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07); + \\ if (bel != null and (esc == null or bel.? < esc.?)) { + \\ bel_terminated = true; + \\ break :blk bel.? + 1; + \\ } + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ return .{ .event = null, .n = 0 }; + , + }, + .{ + .anchor = " const color = try Color.rgbFromSpec(color_spec);\n", + .with = " const color = Color.rgbFromSpec(color_spec) catch return null_event; // pardes's patch (its build.zig)\n", + .count = 2, + }, + .{ + .anchor = + \\ if (input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = if (bel_terminated) + \\ input[semicolon_idx + 3 .. sequence.len - 1] + \\ else + \\ input[semicolon_idx + 3 .. sequence.len - 2]; + \\ const decoder = std.base64.standard.Decoder; + \\ const text = try paste_allocator.?.alloc(u8, try decoder.calcSizeForSlice(payload)); + \\ try decoder.decode(text, payload); + , + .with = + \\ // pardes's patch (its build.zig): a malformed reply is dropped. + \\ const body_end = if (bel_terminated) sequence.len - 1 else sequence.len - 2; + \\ if (semicolon_idx + 3 > body_end or input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = input[semicolon_idx + 3 .. body_end]; + \\ const decoder = std.base64.standard.Decoder; + \\ const gpa = paste_allocator orelse return null_event; + \\ const text = gpa.alloc(u8, decoder.calcSizeForSlice(payload) catch return null_event) catch return null_event; + \\ decoder.decode(text, payload) catch { + \\ gpa.free(text); + \\ return null_event; + \\ }; + , + }, + .{ + .anchor = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ + , + .with = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ // pardes's patch (its build.zig): a cursor position report is no key. + \\ if (final == 'R' and cursorReport(sequence)) return null_event; + \\ + , + }, + .{ + .anchor = " std.debug.assert(sequence.len >= 4); // ESC [ ? c == 4 bytes\n", + .with = " if (sequence.len < 4) return null_event; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = "const delim_idx = std.mem.indexOfScalarPos(u8, input, 3, ';')", + .with = "const delim_idx = std.mem.indexOfScalarPos(u8, sequence, 3, ';')", + .count = 2, + }, + .{ + .anchor = "input[delim_idx + 1 .. sequence.len - 2]", + .with = "sequence[@min(delim_idx + 1, sequence.len - 2) .. sequence.len - 2]", + }, + .{ + .anchor = " if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) {\n", + .with = + \\ // pardes's patch (its build.zig): an X10 report still arriving is incomplete, not text. + \\ if (input.len == 3 and input[2] == 'M' and full_input.len < 6) return .{ .event = null, .n = 0 }; + \\ if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) { + \\ + , + }, + }; + for (edits) |edit| { + if (std.mem.count(u8, src, edit.anchor) != edit.count) std.debug.panic("vaxis's Parser.zig changed at `{s}`: redo its patch in build.zig", .{edit.anchor}); + src = std.mem.replaceOwned(u8, b.allocator, src, edit.anchor, edit.with) catch @panic("OOM"); + } + const cursor_report = + \\ + \\/// pardes's patch (its build.zig): whether `CSI ... R` answers a cursor + \\/// position query. Only `CSI 1 ; mods R` with mods past 1 is a modified F3 + \\/// (a bare F3 is `SS3 R`); a report has another row, or col 1 (mods 1). + \\fn cursorReport(sequence: []const u8) bool { + \\ const params = sequence[2 .. sequence.len - 1]; + \\ if (params.len > 0 and params[0] == '?') return true; + \\ var fields = std.mem.splitScalar(u8, params, ';'); + \\ const row = fields.first(); + \\ const col = fields.next() orelse return false; + \\ if (fields.next() != null or row.len == 0 or col.len == 0) return false; + \\ for (params) |c| if (c != ';' and !std.ascii.isDigit(c)) return false; + \\ return !std.mem.eql(u8, row, "1") or std.mem.eql(u8, col, "1"); + \\} + \\ + ; + const files = b.addWriteFiles(); + _ = files.add("Parser.zig", b.fmt("{s}{s}", .{ src, cursor_report })); + _ = files.addCopyFile(vaxis_dep.path("src/widgets/terminal/Parser.zig"), "widgets/terminal/Parser.zig"); + // Suffixes: this leaves out both Parser.zig files, written above. + _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{"Parser.zig"} }); + vaxis_mod.root_source_file = files.getDirectory().path(b, "main.zig"); + } var ghostty_vt_for_snap: ?*std.Build.Module = null; const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin()); @@ -752,6 +936,8 @@ pub fn build(b: *std.Build) void { for ([_]?*std.Build.Step.Options{ opts, gui_opts }) |maybe| if (maybe) |o| { o.addOption([]const []const u8, "module_names", mod_names); o.addOption([]const []const u8, "module_roots", mod_roots); + // Whose imports those are: a .zig file outside this tree has none of them. + o.addOption([]const u8, "project_root", b.build_root.path orelse ""); }; const iso_off = b.addOptions(); diff --git a/docs/config.md b/docs/config.md index c5a79e4c..349814a6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -49,7 +49,7 @@ opens the file. Shell follows the same requested/effective/pending model as Font. `Default shell` is the one used while no `Shell` is set: `$SHELL`, the user's login shell, else `/bin/sh` (also when `$SHELL` names nothing executable); an explicit `Shell` overrides it. `Shell <name or path>` is -refused unless it names an executable file (`Shell: no shell "x" (...)`, or +refused unless it names an executable file (`Shell: shell "x" not found (...)`, or `Shell: not a shell: /etc is a directory`; a bare name is looked for in the usual bin directories), as `Tty <shell>` is, and a bare `Shell` goes back to the default. The root ctl reads `Shell <the one the @@ -258,7 +258,7 @@ back changes nothing; a setting the frontend cannot show (`Lift`, (EINVAL: a request this build cannot take), and so is a builtin only the GUI has (`Fonts`), written to an `exec` too, where it would otherwise run as a shell command; a `Shell` or `Tty` naming no -shell says `no shell "x", not found` (ENOENT); a `DumpDir` whose last +shell says `shell "x" not found` (ENOENT); a `DumpDir` whose last directory is missing has it made at the Dump, and one further up missing says `Dump <path>: no such directory` (ENOENT), one that is no directory (`/dev/null`) `Dump /dev/null/pardes-<time>.zon: /dev/null is not a @@ -303,7 +303,8 @@ lines before a failing one have taken effect and those after it never run, which is what acme's ctl loop does (editors/acme/xfid.c:600-790). An error that only happens as the editor performs what a line asked for -- a `Save` whose disk write fails -- fails the write too, once the editor has tried -(`Save /root/x.txt: access denied`, EIO), and changes nothing: a scratch +(`Save /root/x.txt: access denied`, which 9ns reads as EACCES, a shell's +`Permission denied`), and changes nothing: a scratch keeps its name and stays a scratch, a clean file stays clean, a dirty one dirty. Like any write, a ctl write answers once the editor has performed what it asked for (a save written, a shell started). A click on @@ -545,7 +546,9 @@ at #0 as sam does (a search never answers where it started), so it finds the next one: for the start itself write `file:0` or `file:#0`. `:addr` does the same in the pane itself, and a pattern may hold blanks (`calc.py:/return a/`). `file:12` selects line 12, its newline included, -as acme's does; `file:12:5` puts the caret at line 12, column 5. A bare +as acme's does; `file:12:5` puts the caret at line 12, column 5 (columns count bytes from 1, +as `addr`'s `12:5` does and as the rows of Grep, +Search and the language +servers write them). A bare `/re/` is a path, as in acme, and failing that a search for its text. A look that finds nothing, an address that does not evaluate, or a line past the file's end (`calc.py:99`) says so on the message row and in the log @@ -559,10 +562,12 @@ directory. A name alone is no edit: the pane's `dirty` stays what its text made Restore or Del, which ask only about text edited), and `Save` writes it under the new name all the same. An open's writes are one name: held until its newline, or its close when it has none, then applied once, however -the writes cut it; nothing else is trimmed. A blank inside a name is taken +the writes cut it; nothing else is trimmed. Two lines are refused, EINVAL, +in one write or as a second line on the same open (bash's `printf +'a\nb\n' > name` writes a line at a time: the first names it). A blank inside a name is taken (`two words.zig`); refused, EINVAL, in acme's words (xfid.c:650) and why, are a blank at either end (not quietly cut off), `bad character in file -name: a blank at its end`, a second line, `...: a newline (a name is one +name: a blank at its start` (or `end`), a second line, `...: a newline (a name is one line)`, a control byte, DEL or a C1 control (U+0080-U+009F), `...: a control character`, and bytes that are not UTF-8, `...: not UTF-8`. `body` appends on write and replaces on truncating open. A terminal's `body` is its history as plain text, frozen per open, in logical @@ -703,10 +708,11 @@ matches at every line start, and `$` may stand just before a `\n` (where it changes nothing). Any other `^` or `$` in such a pattern, `(^|\n)def` or `a\nb$`, is refused, EINVAL, with `bad regular expression: in a pattern with \n, ^ can only come first and $ only just before a \n`, since mvzr would read it as the start or end of the -whole text: never a search that silently finds nothing. Within a line, `^` -inside an alternation (`^def|^ `) matches only where the search starts, so it -works from a line's start (an `x` over lines, a `g` on one) and not from its -middle: an mvzr limit. An expression is +whole text: never a search that silently finds nothing. An alternation +whose every branch starts with `^` (`^def|^ `) finds a line that starts +either way (it is taken as `^(def| )`); one that mixes anchored and +unanchored branches (`^def|x`) is refused, `bad regular expression`, since +mvzr keeps `^` only first. An expression is evaluated from the current address, the range last written to `addr` (or left by the last `data` write, just past it), as acme evaluates it from `w->addr` (xfid.c:446): `.` is that address, not the selection (`dot` is @@ -718,7 +724,7 @@ otherwise wraps to the start of the text; `?re?` and `-/re/` find the last match ending before the range, wrapping to the text's last. The match is the leftmost, but of the alternatives at that place mvzr takes the first that matches where sam takes the longest (`/gam|gamma/` finds `gam`); in a search begun in the -middle of a line, `^` inside an alternation can match there; and in a +middle of a line, `^` inside a group can match there; and in a pattern that spans lines, `^`, `$` and `[^...]` keep mvzr's own meaning. mvzr backtracks without bound of its own (`a?` twenty times then twenty `a`s is 2^20 steps from each place it tries), and a search holds the editor, so pardes patches a @@ -747,7 +753,9 @@ written to `addr` that goes from the current one (`.`, `+1`, `-/re/`), which there is none of, until an address that stands alone (`2`, `#0`, `/re/`) is. The three flag files `dirty`, `mark` and `scroll` read `0` or `1` and take -`0` or `1`: whether the buffer differs from its file, whether a write pushes +`0` or `1`: whether the buffer differs from its file (a file deleted on +disk counts, as in acme: its text is only here now, and Del, Exit and the +rest ask first), whether a write pushes an undo point (writing `1` pushes one now), and whether a write scrolls the pane. The writes of one open of `data`, `xdata` or `body` are one undo step while `mark` is 1 (so `printf 'a\nb\n' > data` is one, though a @@ -768,7 +776,7 @@ name (ENOENT for a pane, file or directory gone -- `look .` from a pane whose directory is gone says `look: <dir>: no such directory`, and a `./zz.txt` or `../x` that is not there `look: ./zz.txt: no such file`, while a plain `zz.txt` is looked for as text, a miss logged as any look's -- and for a -Find or Grep that finds nothing, `grep: no match, not found`; Grep walks +Find or Grep that finds nothing, `grep: pattern not found`; Grep walks every pane's directory on this host, passing over panes of the served tree (`/virtual/`, a peer's `/n/<name>/`) and directories not there, so none of them spoils the rest; Find, Grep and a language server's lists @@ -788,7 +796,7 @@ a command pane ends in its own time, told by its `exit` record. `tag` reads the whole tag as the pane shows it: the computed path or PDF page (no mark for unsaved text: the grip shows that, and `dirty` says it), then the text you may edit. An image's tag begins with its mode words, not -its path: `img petscii:off palette:terminal ascii:off <path>`, the modes +its path: `img petscii:off palette:commodore ascii:on <path>` by default, the modes it is drawn in, then the file. A write appends to that text, newlines included, and a tag with more than one line takes a row per line on screen; truncating `tag` clears it, as acme's `cleartag` does -- the default diff --git a/docs/tags.md b/docs/tags.md index c2d92b9a..3ff5e0ef 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -305,8 +305,8 @@ the left column closes the column's lone shell if nobody has typed into it. Down from an empty column's tag stays there, and the pane-to-pane keys pass over an empty column; Left and Right from a tag walk every column's tag. -One divergence from acme: acme keeps running when its last window closes, -every column empty. Pardes quits when the session's last pane closes, as -`Delcol` of the last column always has. A key, a prompt or a command in -pardes runs in a pane, so a session without one would have nothing to run -them in. +`Delcol` of the last column does as acme's does: the column goes and the +window stays, empty but for the workspace tag, where `Newcol` (or `New`, +which makes the column it goes in) starts it again. One divergence from +acme remains: acme keeps running when its last window closes; pardes quits +when the session's last pane closes by `Del`, a shell exiting or the like. diff --git a/src/9p_io.zig b/src/9p_io.zig index 96f91bc1..91240b57 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -865,7 +865,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ return null; }; _ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: { - const existing = std.Io.Dir.cwd().statFile(io, p, .{ .follow_symlinks = false }) catch null; + 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)) { log.warn("something is already listening on {s}", .{p}); l.deinit(gpa); @@ -1638,6 +1638,7 @@ extern "c" fn unsetenv(name: [*:0]const u8) c_int; pub fn start(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listener { var name: [16]u8 = undefined; + // unreachable: a u32 is at most 10 digits const fallback = std.fmt.bufPrint(&name, "{d}", .{@as(u32, @intCast(libc.getpid()))}) catch unreachable; return listen(io, gpa, core, core.opts.ninep_name, fallback, core.opts.ninep_tcp, core.opts.ninep_quic) orelse { core.reportError(0, "9p listener", error.ListenFailed); diff --git a/src/Mini.zig b/src/Mini.zig index ac9a8e85..a6aef15a 100644 --- a/src/Mini.zig +++ b/src/Mini.zig @@ -49,6 +49,7 @@ const Row = struct { const end = modal.nextGrapheme(row.text, row.at); const grapheme = row.text[row.at..end]; row.left = File.graphemeDisplayWidth(grapheme); + // unreachable below: `generate` refuses a source that is no UTF-8 const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable; const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable; const blank = switch (cp) { @@ -108,6 +109,7 @@ fn render(output: ?Result, source: []const u8, styles: []const u8) !usize { if (output) |out| { @memset(out.content[offset..][0..spaces], ' '); @memset(out.colors[offset..][0..spaces], 0); + // unreachable: U+2800 plus a u8 mask is a braille codepoint, always three bytes _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable; @memset(out.colors[offset + spaces ..][0..3], color); } @@ -313,3 +315,7 @@ test "Mini publishes only complete snapshots and content replacement drops metad const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path}); } + +test "Mini refuses a file that is no UTF-8, before its decoding could panic" { + try std.testing.expectError(error.InvalidUtf8, generate(std.testing.allocator, "ok \xff\xfe bad\n\xc3", "")); +} diff --git a/src/Terminal.zig b/src/Terminal.zig index 32595d25..c99a92a6 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1113,6 +1113,11 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { const pane = p.panes[id] orelse return; const state = pane.terminal orelse return; const input = ghostty_vt.input; + const function_keys = comptime keys: { + var keys: [Key.f25 - Key.f1 + 1]input.Key = undefined; + for (&keys, 1..) |*k, n| k.* = @field(input.Key, std.fmt.comptimePrint("f{d}", .{n})); + break :keys keys; + }; const physical: input.Key = switch (key.cp) { Key.enter => .enter, Key.backspace => .backspace, @@ -1127,11 +1132,21 @@ pub fn forwardKey(p: *Pardes, id: usize, key: Key) void { Key.page_up => .page_up, Key.page_down => .page_down, Key.delete => .delete, + Key.insert => .insert, + Key.print_screen => .print_screen, + Key.scroll_lock => .scroll_lock, + Key.pause => .pause, + Key.menu => .context_menu, + Key.f1...Key.f25 => function_keys[key.cp - Key.f1], else => if (key.cp < 128) input.Key.fromASCII(@intCast(key.cp)) orelse .unidentified else .unidentified, }; var utf8: [4]u8 = undefined; const text_cp = if (key.shift and key.cp >= 'a' and key.cp <= 'z') key.cp - 'a' + 'A' else key.cp; - const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace) + // A key with no text of its own types its code point only when that + // is a character: the kitty keyboard protocol numbers function and + // modifier keys in the private use area (F3 is U+E016), and a shell + // was typed those as junk. + const text = if (key.text.len > 0) key.text else if (key.cp >= 32 and key.cp < Key.up and key.cp != Key.backspace and !(key.cp >= 0xE000 and key.cp <= 0xF8FF)) utf8[0 .. std.unicode.utf8Encode(text_cp, &utf8) catch return] else ""; diff --git a/src/builtins.zig b/src/builtins.zig index b0499dc0..8788e65f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -536,13 +536,18 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { // under 100 bytes it is not asked about, as acme's winclean does not // whine about a small unnamed window, nor about a command's output. const scratch = if (f.output) |o| o.from == .cmd and o.from.cmd == .New else false; - if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue; - if (f.output != null and f.content.len < 100) continue; - // Dirty by a rename alone is no text to lose, as get says (ctl.zig). - // Nor is a buffer never edited at all: its first edit is what keeps - // the text it had (File.zig), so with none there is nothing to lose. - const saved = f.saved_hash orelse continue; - if (std.hash.Wyhash.hash(0, f.content) == saved) continue; + // A file deleted on disk is asked about, edited or not: its text is + // nowhere else now (acme's winclean asks the same). + const gone = f.disk_gone and f.output == null; + if (!gone) { + if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue; + if (f.output != null and f.content.len < 100) continue; + // Dirty by a rename alone is no text to lose, as get says (ctl.zig). + // Nor is a buffer never edited at all: its first edit is what keeps + // the text it had (File.zig), so with none there is nothing to lose. + const saved = f.saved_hash orelse continue; + if (std.hash.Wyhash.hash(0, f.content) == saved) continue; + } if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue; pane.discard_warned = .{ .revision = f.revision, .by = asking }; // Scratches share a name: each is named with its pane's serial too, @@ -803,10 +808,14 @@ pub const ThemeFile = struct { pub const requires_arg = true; pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { - if (comptime enabled) - colors.requestThemeFile(c.p, c.id, c.arg orelse return) - else - unreachable; + if (comptime !enabled) unreachable; + const arg = std.mem.trim(u8, c.arg orelse return, " \t\r\n"); + // Said with why, not "invalid theme path". + if (!std.mem.endsWith(u8, arg, ".zon")) { + var said: [320]u8 = undefined; + return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "ThemeFile: {s}: invalid theme path: a theme file is a .zon (DumpThemes writes some to start from)", .{arg[0..@min(arg.len, 200)]}) catch "ThemeFile: invalid theme path: a theme file is a .zon"); + } + colors.requestThemeFile(c.p, c.id, arg); } }; @@ -1375,8 +1384,8 @@ pub const Mini = struct { /// A Find or Grep that finds nothing fails as one, in words 9ns reads as /// ENOENT (`not found`), the same for both. -const e_search_miss_find = "find: no match, not found"; -const e_search_miss_grep = "grep: no match, not found"; +const e_search_miss_find = "find: name not found"; +const e_search_miss_grep = "grep: pattern not found"; /// List the files below this pane's directory whose names hold its argument, in a +Search. pub const Find = struct { @@ -1818,6 +1827,7 @@ const Board = struct { writeWord(addr, value); const back = readWord(addr); var buf: [96]u8 = undefined; + // unreachable below: three 8-digit hex words and 18 bytes fit 96 p.setMessage(id, std.fmt.bufPrint( &buf, "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", @@ -1840,6 +1850,7 @@ const Board = struct { if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; var buf: [48]u8 = undefined; + // unreachable: three small numbers and 10 bytes fit 48 p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); } diff --git a/src/detached/server.zig b/src/detached/server.zig index 676b5b95..b536e4a7 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1214,6 +1214,7 @@ pub const Session = struct { fn refuseFd(_: *Session, fd: c_int, why: wire.Refusal) void { var buf: [wire.header_len + 1]u8 = undefined; + // unreachable: a refusal is a header and one byte, which buf is sized to const bytes = wire.encodeServer(&buf, .{ .refuse = why }) catch unreachable; var off: usize = 0; while (off < bytes.len) { diff --git a/src/dump.zig b/src/dump.zig index 45a5e0eb..fca88a2d 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -47,11 +47,40 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { const yd = es.getEpochDay().calculateYearDay(); const md = yd.calculateMonthDay(); const ds = es.getDaySeconds(); - return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}.zon", .{ - dir, yd.year, md.month.numeric(), - @as(u8, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), - ds.getSecondsIntoMinute(), - }, 0) catch null; + var stamp: [32]u8 = undefined; + const base = std.fmt.bufPrint(&stamp, "pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{ + yd.year, md.month.numeric(), @as(u8, md.day_index) + 1, + ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), + }) catch return null; + return uniqueIn(buf, dir, base); +} + +/// `<dir>/<base>.zon`, or `<base>-2.zon` and on when two dumps land in one +/// second: a dump never overwrites another. +fn uniqueIn(buf: *[1024:0]u8, dir: []const u8, base: []const u8) ?[:0]const u8 { + var n: usize = 1; + while (n < 1000) : (n += 1) { + const path = (if (n == 1) + std.fmt.bufPrintSentinel(buf, "{s}/{s}.zon", .{ dir, base }, 0) + else + std.fmt.bufPrintSentinel(buf, "{s}/{s}-{d}.zon", .{ dir, base, n }, 0)) catch return null; + if (comptime !pardes.hosted) return path; + if (std.c.access(path.ptr, std.c.F_OK) != 0) return path; + } + return null; +} + +test "two dumps in one second get names of their own" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [900]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var buf: [1024:0]u8 = undefined; + const first = try std.testing.allocator.dupe(u8, uniqueIn(&buf, dir, "pardes-x").?); + defer std.testing.allocator.free(first); + try std.testing.expect(std.mem.endsWith(u8, first, "/pardes-x.zon")); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes-x.zon", .data = "" }); + try std.testing.expect(std.mem.endsWith(u8, uniqueIn(&buf, dir, "pardes-x").?, "/pardes-x-2.zon")); } /// The host's environment; a freestanding core (web, the board) has none /// and no libc to ask, so only a DumpDir with no `~/` names a directory there. diff --git a/src/edit.zig b/src/edit.zig index a194ef48..183d6325 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -487,6 +487,19 @@ fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { /// install a rewritten editable text (frees the old one) pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { + setEditTextEnd(p, t, new, null); +} + +/// Where text `s` put in at `c` ends: `g.`'s place after typing it. +fn insertedEnd(c: modal.Cursor, s: []const u8) modal.Cursor { + const nl = std.mem.lastIndexOfScalar(u8, s, '\n') orelse return .{ .row = c.row, .col = c.col + s.len }; + return .{ .row = c.row + std.mem.count(u8, s, "\n"), .col = s.len - nl - 1 }; +} + +/// `setEditText` with where the edit ended in `new` (in the edit text's +/// rows) when the caller knows it, as typing does: then nothing scans the +/// whole text to find it. Null, it is found by comparing the texts. +pub fn setEditTextEnd(p: *Pardes, t: *Text, new: []u8, known_end: ?modal.Cursor) void { if (t.what == .tag) { // A tag is edited as it is shown, but only what follows its // prefix is its own: an edit that changed the prefix is refused. @@ -512,10 +525,14 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { const pane = t.pane() orelse return retireEditText(p, new); // what changed ended where the texts agree again from their ends const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 }; - const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); - var back: usize = 0; - while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; - const end = modal.positionAt(new, new.len - back); + const end = known_end orelse found: { + // ponytail: O(file), for the edits that do not say where they ended + // (paste, a pipe, s): typing passes its end and never comes here. + const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); + var back: usize = 0; + while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; + break :found modal.positionAt(new, new.len - back); + }; t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); @@ -813,7 +830,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }; if (key.text.len > 0 and !key.ctrl and !key.alt) { const new = modal.insertAt(p.gpa, text, c, key.text) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, insertedEnd(c, key.text)); for (key.text) |ch| { if (ch == '\n') { t.cur_row += 1; @@ -837,7 +854,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const from = @min(wr.anchor, wr.head); const fc = modal.positionAt(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, fc); t.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; t.cur_col = @intCast(fc.col); t.cur_pinned = true; @@ -855,13 +872,13 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const to = @max(wr.anchor, wr.head); if (to <= g) return; const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, c2); t.cur_pinned = true; return; } if (hit(key, config.kill_to_line_start)) { const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row, .col = 0 }); t.cur_col = 0; t.cur_pinned = true; t.show(); @@ -870,7 +887,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { if (hit(key, config.kill_to_line_end)) { const llen = modal.lineSlice(text, c.row).len; const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, c); t.cur_pinned = true; return; } @@ -883,7 +900,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { // cursor go and the new line takes the indent if (std.mem.indexOfNone(u8, before, " \t") == null) { const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row + 1, .col = 0 }); t.cur_row += 1; t.cur_pinned = true; t.show(); @@ -900,7 +917,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return; defer p.gpa.free(cut); const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row + 1, .col = indent }); t.cur_row += 1; t.cur_col = @intCast(indent); t.cur_pinned = true; @@ -922,7 +939,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1; } const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row, .col = prev }); t.cur_col = @intCast(prev); t.cur_pinned = true; t.show(); @@ -936,7 +953,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { if (row == 0) return; const prevlen = modal.lineSlice(e2.text, row - 1).len; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = row - 1, .col = prevlen }); t.cur_row -= 1; t.cur_col = @intCast(prevlen); t.cur_pinned = true; @@ -948,13 +965,13 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const line = modal.lineSlice(text, c.row); if (c.col < line.len) { const new = modal.deleteChar(p.gpa, text, c) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, c); } else { const e2 = editText(p, t, t.cur_row, t.cur_row + 1, t.cur_col) orelse return; const row: usize = @intCast(@max(0, t.cur_row - e2.row0)); if (row + 1 >= modal.lineCount(e2.text)) return; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = row, .col = c.col }); } t.cur_pinned = true; }, @@ -993,7 +1010,7 @@ pub fn insertTab(p: *Pardes, t: *Text) void { }; const pad = modal.INDENT_W - (c.col % modal.INDENT_W); const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row, .col = c.col + pad }); t.cur_col += @intCast(pad); t.cur_pinned = true; t.show(); @@ -2365,3 +2382,26 @@ test "double-click selects as acme's does" { const e = doubleClickRange(lines, 7); try std.testing.expectEqualStrings("two\n", lines[e[0]..e[1]]); } + +test "a typed edit's known end is where the texts' comparison would find it" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo three\nfour\n"); + const t = &pane.body; + const cases = [_]struct { at: modal.Cursor, s: []const u8 }{ + .{ .at = .{ .row = 1, .col = 3 }, .s = "xy" }, + .{ .at = .{ .row = 0, .col = 0 }, .s = "a\nbc" }, + .{ .at = .{ .row = 2, .col = 4 }, .s = "\n" }, + }; + for (cases) |c| { + const base = try p.gpa.dupe(u8, pane.file.?.content); + defer p.gpa.free(base); + const scanned = try modal.insertAt(p.gpa, base, c.at, c.s); + setEditTextEnd(p, t, scanned, null); + const by_scan = t.last_edit.?; + const known = try modal.insertAt(p.gpa, base, c.at, c.s); + setEditTextEnd(p, t, known, insertedEnd(c.at, c.s)); + try std.testing.expectEqual(by_scan.row, t.last_edit.?.row); + try std.testing.expectEqual(by_scan.col, t.last_edit.?.col); + } +} diff --git a/src/esp32p4_9p.zig b/src/esp32p4_9p.zig index e23235a0..cd59b9df 100644 --- a/src/esp32p4_9p.zig +++ b/src/esp32p4_9p.zig @@ -103,6 +103,7 @@ fn die(msg: []const u8) noreturn { .{ .rerror = .{ .ename = msg[0..@min(msg.len, ninep.errmax)] } }, ninep.notag, &buf, + // unreachable: the message is cut to errmax and buf is sized to it ) catch unreachable; uart.write(bytes); while (true) {} diff --git a/src/exec.zig b/src/exec.zig index 88c0705d..06e5d375 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -391,7 +391,7 @@ pub fn isDirectory(path: []const u8) bool { const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return false; pardes.turn.yield(); defer pardes.turn.back(); - const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false; + const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false; return stat.kind == .directory; } @@ -407,7 +407,7 @@ pub fn kindOf(path: []const u8) ?std.Io.File.Kind { const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return null; pardes.turn.yield(); defer pardes.turn.back(); - const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return null; + const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return null; return stat.kind; } @@ -643,7 +643,12 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { /// such text may be a bound REPL's. Run by name (`Exec ...`, whose line /// comes back through `execute`) or written as a command word, never. pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize { - const pane = p.panes[id] orelse return null; + // An empty window runs its session words, and nothing else: no pane to + // run a command for. + const pane = p.panes[id] orelse { + _ = executeBuiltinLine(p, id, commandText(txt)); + return null; + }; const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null; const cmd = commandText(txt); if (cmd.len == 0) return null; @@ -891,7 +896,9 @@ pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]co /// `runBuiltin` saying whether a gesture on the pane's body made it. pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8, from_body: bool) void { if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor - const pane = p.panes[id] orelse return; + // No pane at all (the window Delcol emptied): a word runs all the same, + // with a stand-in that is no pane (New, Newcol, Exit are what make sense). + const pane = p.panes[id] orelse (if (builtins.registry.scope(b) == .session or p.anyPane() == null) p.standIn() orelse return else return); // A builtin says its own name before it runs, so a tagline click is // legible even when what it did is invisible. Whatever the builtin // then says for itself replaces the announcement. It is the message @@ -1000,6 +1007,9 @@ pub const NewPane = enum { scratch, fs, shell, doc, command, errors }; /// from: null when a column tag, the workspace tag or 9P asked (acme's /// t->w == nil). pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void { + // No column left (the last one Delcol'd): a pane made from the workspace + // tag opens one to go in. + _ = layout.firstColumn(p); const asked = if (p.exec_column != null) null else origin; layout.compute(p); const before = p.rects; diff --git a/src/file_watch.zig b/src/file_watch.zig index 8bd9de8b..148bc836 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -397,7 +397,7 @@ fn watchPath( // stat and a lookup out there: the turn goes out with them. pardes.turn.yield(); defer pardes.turn.back(); - const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; + const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; const dir = if (stat != null and stat.?.kind == .directory) watched_path else std.fs.path.dirname(watched_path) orelse "."; var dir_buf: [4096:0]u8 = undefined; if (dir.len >= dir_buf.len) return; @@ -576,12 +576,19 @@ pub fn applyThemeEffect( watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 }); if (!on) return false; const request = colors.themeFileRequest(core, generation) orelse return false; + // What fails here is the ThemeFile write's failure too, which waits on + // this (the one-failure rule): late, as a Save's. + const pane = request.pane; const bytes = filesystem.readFile(gpa, request.path) catch |err| { colors.failThemeFile(core, generation, err); + core.noteLateFailure(pane); return false; }; defer gpa.free(bytes); - if (!colors.loadThemeFile(core, generation, bytes, animate)) return false; + if (!colors.loadThemeFile(core, generation, bytes, animate)) { + core.noteLateFailure(pane); + return false; + } const hash = std.hash.Wyhash.hash(0, bytes); watchPath(fd, watches, theme_slot, request.path, generation, .{ .text = hash }); if (watches[theme_slot] == null) return false; @@ -631,7 +638,7 @@ pub fn identify(io: std.Io, path: []const u8) !Identity { const native = filesystem.localPath(path) orelse return error.NonLocalPath; pardes.turn.yield(); defer pardes.turn.back(); - const stat = try std.Io.Dir.cwd().statFile(io, native, .{}); + const stat = try @import("fs.zig").statPath(io, native, .{}); if (stat.kind != .file) return error.NotFile; return .{ .inode = stat.inode, diff --git a/src/fonts.zig b/src/fonts.zig index 4cfba44c..098bf36f 100644 --- a/src/fonts.zig +++ b/src/fonts.zig @@ -324,6 +324,7 @@ fn sfntBase(head: []const u8) ?usize { fn scratchFont(buf: *[64:0]u8, ext: []const u8) [:0]const u8 { return std.fmt.bufPrintSentinel(buf, "/tmp/pardes-fonts-test-{d}{s}", .{ @as(u32, @intCast(libc.getpid())), ext, + // unreachable: 23 bytes, a u32 pid (10) and a short extension fit 64 }, 0) catch unreachable; } @@ -159,7 +159,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { const stat = stat: { pardes.turn.yield(); defer pardes.turn.back(); - break :stat std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); + break :stat statPath(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); }; const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o755 else 0o644, .mtime = std.math.cast(u32, stat.mtime.toSeconds()) orelse 0 }; switch (req.op) { @@ -622,6 +622,21 @@ pub fn isVirtual(path: []const u8) bool { std.mem.eql(u8, path, "/n") or std.mem.startsWith(u8, path, "/n/"); } +/// std's `statFile`, but a name with a part over 255 bytes is NameTooLong: +/// std's own takes the kernel's ENAMETOOLONG for that for a bug and panics +/// (Io.Threaded dirStatFileLinux), and such a name comes from anyone who +/// writes one to `name`, `look` or DumpDir. +pub fn statPath(io: std.Io, path: []const u8, options: std.Io.Dir.StatFileOptions) !std.Io.File.Stat { + var parts = std.mem.tokenizeScalar(u8, path, '/'); + while (parts.next()) |part| if (part.len > 255) return error.NameTooLong; + return std.Io.Dir.cwd().statFile(io, path, options); +} + +test "a path with a part over 255 bytes is NameTooLong, never a panic" { + const long = "/tmp/" ++ "x" ** 300 ++ "/f"; + try std.testing.expectError(error.NameTooLong, statPath(std.testing.io, long, .{})); +} + pub fn localPath(path: []const u8) ?[]const u8 { if (std.mem.eql(u8, path, "/n/os")) return "/"; if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d935b5fd..b34b314d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -937,6 +937,8 @@ const Queue = struct { completion_len: usize = 0, fn lock(q: *Queue) void { + // unreachable failures: pthread calls on this queue's own initialised, non-recursive + // mutex and condition fail only on misuse (EINVAL, EDEADLK), never on input. std.debug.assert(libc.pthread_mutex_lock(&q.mutex) == .SUCCESS); } @@ -1061,9 +1063,19 @@ const Queue = struct { } fn take(q: *Queue) MessageBatch { + var batch: MessageBatch = .{}; + q.takeInto(&batch); + return batch; + } + + /// `take` into a batch the caller keeps: an idle wakeup copies nothing + /// (the batch is some 20 KB, and was built and returned by value each + /// time). + fn takeInto(q: *Queue, batch: *MessageBatch) void { + batch.len = 0; q.lock(); defer q.unlock(); - var batch: MessageBatch = .{}; + if (q.len == 0 and !q.files_changed and q.completion_len == 0) return; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; batch.len += 1; @@ -1080,7 +1092,6 @@ const Queue = struct { batch.len += q.completion_len; q.completion_len = 0; std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); - return batch; } fn discardCompletions(q: *Queue) void { @@ -3999,6 +4010,8 @@ const Shell = struct { ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, queue: *Queue, + /// What drainQueue takes each wakeup, kept rather than rebuilt. + batch: MessageBatch = .{}, pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, @@ -4153,7 +4166,8 @@ const Shell = struct { s.reconcilePtys(); // After this batch's output, which an exit is told behind. defer s.takeExits(); - var msgs = s.queue.take(); + s.queue.takeInto(&s.batch); + const msgs = &s.batch; var check_files = false; for (msgs.slice()) |m| switch (m) { .output => |o| { @@ -8322,6 +8336,7 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t const pixels: [*]const u8 = @ptrCast(mapped); try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); var name: [24]u8 = undefined; + // unreachable below: "transition-" or "frame-" and a u32 fit 24 if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable); g.captured +%= 1; diff --git a/src/host_io.zig b/src/host_io.zig index 8bbb0985..9e3fbf79 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -933,7 +933,7 @@ pub const Shell = struct { if (libc.access(@ptrCast(&buf), 0) == 0) return std.fmt.bufPrint(said, "not a shell: {s} is not executable", .{bin}) catch "not a shell: not executable"; } // "not found": the words a mount reads as ENOENT. - return std.fmt.bufPrint(said, "no shell \"{s}\", not found (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell"; + return std.fmt.bufPrint(said, "shell \"{s}\" not found (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell"; } /// The program a script's `#!` line names, when it is not there to @@ -970,7 +970,7 @@ pub const Shell = struct { var said: [320]u8 = undefined; try std.testing.expectEqualStrings("interpreter /no/such/interp not found", refusal(try std.fmt.bufPrint(&path, "{s}/bad", .{dir}), &said).?); try std.testing.expect(refusal(try std.fmt.bufPrint(&path, "{s}/good", .{dir}), &said) == null); - try std.testing.expect(std.mem.startsWith(u8, refusal(try std.fmt.bufPrint(&path, "{s}/none", .{dir}), &said).?, "no shell ")); + try std.testing.expect(std.mem.startsWith(u8, refusal(try std.fmt.bufPrint(&path, "{s}/none", .{dir}), &said).?, "shell \"")); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain", .data = "echo\n" }); const plain = try std.fmt.bufPrint(&path, "{s}/plain", .{dir}); try std.testing.expect(std.mem.endsWith(u8, refusal(plain, &said).?, "plain is not executable")); @@ -1048,7 +1048,7 @@ pub const Shell = struct { var buf: [2048]u8 = undefined; try std.testing.expectEqualStrings(bash_rc, readSmall(a_bash, &buf) orelse return error.ReadFailed); try std.testing.expectEqualStrings(fish_rc, readSmall(a.fishPath().?, &buf) orelse return error.ReadFailed); - const stat = try std.Io.Dir.cwd().statFile(std.testing.io, a_bash, .{}); + const stat = try @import("fs.zig").statPath(std.testing.io, a_bash, .{}); try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077); var kept: [rc_path_capacity:0]u8 = @splat(0); @memcpy(kept[0..a_bash.len], a_bash); @@ -1075,6 +1075,45 @@ pub const Shell = struct { } }; +/// In a child between fork and exec: every signal back to its default and +/// none blocked, whatever this process ignores or blocks (the tty's SIGWINCH, +/// a SIGHUP nohup ignored). A handler resets at exec by itself; an ignored +/// signal and the mask do not. Async-signal-safe: sigaction and sigprocmask. +pub fn resetChildSignals() void { + const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 }; + var sig: u8 = 1; + while (sig < 65) : (sig += 1) { + if (sig == @intFromEnum(posix.SIG.KILL) or sig == @intFromEnum(posix.SIG.STOP)) continue; + _ = std.c.sigaction(@enumFromInt(sig), &default, null); + } + const none = posix.sigemptyset(); + posix.sigprocmask(posix.SIG.SETMASK, &none, null); +} + +test "a child starts with every signal at its default and none blocked" { + var blocked = posix.sigemptyset(); + posix.sigaddset(&blocked, posix.SIG.WINCH); + posix.sigprocmask(posix.SIG.BLOCK, &blocked, null); + defer posix.sigprocmask(posix.SIG.UNBLOCK, &blocked, null); + const ignore: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.IGN }, .mask = posix.sigemptyset(), .flags = 0 }; + var was: posix.Sigaction = undefined; + posix.sigaction(posix.SIG.HUP, &ignore, &was); + defer posix.sigaction(posix.SIG.HUP, &was, null); + const pid = libc.fork(); + if (pid == 0) { + resetChildSignals(); + var now: posix.Sigaction = undefined; + posix.sigaction(posix.SIG.HUP, null, &now); + var mask = posix.sigemptyset(); + posix.sigprocmask(posix.SIG.BLOCK, null, &mask); + const ok = now.handler.handler == posix.SIG.DFL and !posix.sigismember(&mask, posix.SIG.WINCH); + libc._exit(if (ok) 0 else 1); + } + var status: c_int = 0; + _ = libc.waitpid(pid, &status, 0); + try std.testing.expectEqual(@as(c_int, 0), status); +} + extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn execve(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int; @@ -1202,7 +1241,7 @@ pub fn forkShell( // A shell's directory may be inside a mount this editor serves. pardes.turn.yield(); defer pardes.turn.back(); - const stat = try std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), path, .{}); + const stat = try @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), path, .{}); if (stat.kind != .directory) return error.NotDir; break :dir path; }; @@ -1278,9 +1317,7 @@ pub fn forkShell( return error.ForkFailed; } if (pid == 0) { - var set = posix.sigemptyset(); - posix.sigaddset(&set, posix.SIG.WINCH); - posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); + resetChildSignals(); if (cwd_z) |path| if (chdir(path.ptr) != 0) { const why = [_]u8{ 'c', @truncate(@intFromEnum(libc.errno(@as(c_int, -1)))) }; _ = libc.write(told[1], &why, why.len); @@ -1440,7 +1477,7 @@ pub fn shellCwd(pid: libc.pid_t, buf: []u8) ?[]const u8 { // no directory to be named by, so the name it had stays, and a // restart works there once it is back. if (std.mem.endsWith(u8, link, " (deleted)")) { - _ = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), link, .{}) catch return null; + _ = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), link, .{}) catch return null; } return link; }, diff --git a/src/layout.zig b/src/layout.zig index 16af1ec5..28f567bc 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -635,6 +635,19 @@ fn canSplitCol(p: *Pardes, col: usize) bool { /// An empty column beside column `col`, with half its width: acme's Newcol /// (rowadd, rows.c:51). The new column's index, or null with no room. +/// A window with no column left (Delcol took the last, as acme lets it): +/// its first one, empty; null when it has columns. +pub fn firstColumn(p: *Pardes) ?usize { + if (p.ncol != 0) return null; + p.col_weight[0] = 2; + p.col_tags[0] = .{ .what = .column, .gpa = p.gpa }; + p.col_serial[0] = 0; + p.col_n[0] = 0; + p.ncol = 1; + noteColumn(p, "newcol", columnSerial(p, 0)); + return 0; +} + pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { if (!canSplitCol(p, col)) return null; var old_weight = p.col_weight[col]; diff --git a/src/linux/v9fs.zig b/src/linux/v9fs.zig index 4e18660d..5e031bfe 100644 --- a/src/linux/v9fs.zig +++ b/src/linux/v9fs.zig @@ -118,6 +118,16 @@ fn launch(arena: std.mem.Allocator, args: []const [:0]const u8) !u8 { const pid = fork(); if (pid < 0) return error.ForkFailed; if (pid == 0) { + // host_io's resetChildSignals, here in its own module: every signal + // back to its default and none blocked before the exec. + const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 }; + var sig: u8 = 1; + while (sig < 65) : (sig += 1) { + if (sig == @intFromEnum(std.posix.SIG.KILL) or sig == @intFromEnum(std.posix.SIG.STOP)) continue; + _ = std.c.sigaction(@enumFromInt(sig), &default, null); + } + const none = std.posix.sigemptyset(); + std.posix.sigprocmask(std.posix.SIG.SETMASK, &none, null); _ = execvp("sudo", argv.ptr); _exit(127); } diff --git a/src/look.zig b/src/look.zig index 5cf26240..79cbd50d 100644 --- a/src/look.zig +++ b/src/look.zig @@ -49,6 +49,7 @@ pub fn openLink(url: []const u8) void { if (pid < 0) return; if (pid == 0) { if (fork() == 0) { + @import("host_io.zig").resetChildSignals(); const argv: [3:null]?[*:0]const u8 = .{ opener, url_z.ptr, null }; _ = execv(opener, &argv); } @@ -1414,13 +1415,21 @@ pub fn reopenAt(p: *Pardes, from: usize, path: []const u8, at: Spot) void { } clearNavigationSelection(p, pane); focusPaneLine(p, id, to, .center); + // Said when the file no longer has the line, closed or open: the place + // is its last line now. + if (at.line > n or at.end_line > n) { + var said: [miss_path_cap + 64]u8 = undefined; + var cut: [miss_path_cap + "…".len]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&said, "{s} has {d} lines now: at its last", .{ missText(path, &cut), n }) catch "at its last line"); + } } -/// Whether `pane` is the +Recent list, whose rows are remembered places. +/// Whether `pane` is the +Recent or the Jumplist list, whose rows are +/// remembered places: a look at one clamps to the file as it is now. fn isRecentList(pane: *const Pane) bool { const f = pane.file orelse return false; const o = f.output orelse return false; - return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }); + return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist }); } pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { @@ -1539,8 +1548,12 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { if (comptime pdf_enabled) if (target.kind == .pdf) { if (focusPaneByPath(p, target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| - return p.reportError(id, "look", err); + // Said with its path and why, as an image that cannot be read is. + _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| { + var said: [miss_path_cap + 64]u8 = undefined; + var cut: [miss_path_cap + "…".len]u8 = undefined; + return p.reportError(id, std.fmt.bufPrint(&said, "look: {s}: not a PDF pardes can read", .{missText(target.path, &cut)}) catch "look: not a PDF pardes can read", err); + }; exec.placeNew(p, id, id, free, .doc); return; }; diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index 55df1f91..7c1980f6 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -429,6 +429,8 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out record(si, req, us, rows, err_name); if (req.kind == .explain) return traceOut(&tr, req, out, rows, us); + // No server to start for the language: said, not "did not answer". + if (failure) |err| if (err == error.NoServer) return lsp.none(out, "language server for {s} not found", .{std.fs.path.extension(req.path)}); if (failure) |err| return err; try out.writeAll(scratch.buffered()); } @@ -1148,6 +1150,7 @@ fn ensure(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, tr: *Trac // signal-safe calls, no allocation, no locks. (Same rule as tty.zig's // forkShell.) _ = setsid(); // Ctrl-C in pardes's terminal is not the server's business + @import("../host_io.zig").resetChildSignals(); _ = libc.dup2(sv[1], 0); _ = libc.dup2(sv[1], 1); const devnull = libc.open("/dev/null", .{ .ACCMODE = .WRONLY }); @@ -2230,3 +2233,16 @@ test "LSP source snippets preserve indentation with UTF16 byte ranges and prose } } } + +test "a language with no server to start says so, not that it did not answer" { + const si = specFor("/x/a.py") orelse return; + const was = conns[si].state; + conns[si].state = .disabled; + defer conns[si].state = was; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + var buf: [256]u8 = undefined; + var out: std.Io.Writer = .fixed(&buf); + try query(std.testing.allocator, arena.allocator(), .{ .kind = .hover, .path = "/x/a.py", .source = "", .offset = 0 }, &out); + try std.testing.expectEqualStrings("@none language server for .py not found\n", out.buffered()); +} diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index 09493c26..2311ec47 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -354,10 +354,14 @@ fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { // The dependency module map, which is the answer to "why does gd work on // std and not on @import(\"vaxis\")" — if a name is not on this list, the // build did not import it and nothing can resolve it. - try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); - for (cfg.module_names, cfg.module_roots) |n, r| { - try out.print(" {s:<14} {s}\n", .{ n, r }); - } + // pardes's own build's imports: listed for a file in its tree, none for + // any other project's file, which does not have them. + if (inProject(req.path)) { + try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); + for (cfg.module_names, cfg.module_roots) |n, r| { + try out.print(" {s:<14} {s}\n", .{ n, r }); + } + } else try out.print("\ndependency imports gd can follow: none (this file is not in the tree pardes was built from)\n", .{}); try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path}); try out.print("\nanswers:", .{}); @@ -426,6 +430,20 @@ fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { /// running ITS build.zig. /// 3. A module whose root source is a GENERATED file is absent from the /// table: it has no path until make() runs. +/// Whether `path` is in the tree pardes was built from, whose build's +/// imports `cfg.module_names` are. +fn inProject(path: []const u8) bool { + const project = std.mem.trimEnd(u8, cfg.project_root, "/"); + return project.len > 0 and path.len > project.len and std.mem.startsWith(u8, path, project) and path[project.len] == '/'; +} + +test "only a file in pardes's own tree is told pardes's dependency imports" { + var buf: [4096]u8 = undefined; + const inside = try std.fmt.bufPrint(&buf, "{s}/src/main.zig", .{std.mem.trimEnd(u8, cfg.project_root, "/")}); + try std.testing.expect(inProject(inside)); + try std.testing.expect(!inProject("/tmp/elsewhere/main.zig")); +} + fn moduleRoot(name: []const u8) ?[]const u8 { comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len); for (cfg.module_names, cfg.module_roots) |n, r| { diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index b93d83c3..397f3339 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -52,7 +52,7 @@ fn noteResult(p: *Pardes, serial: u32) void { /// created; failing that, a look records the pane it focused and an exec /// the pane it acted on (even one it closed). pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) void { - const context = p.panes[id].?.serial; + const context = if (p.panes[id]) |pane| pane.serial else 0; // A pane an event reader holds hears its clicks instead of acting on // them (editors/acme/look.c:53 and exec.c:173 send them to winevent), // and a line written to a look or exec is a click: on that pane, or for @@ -260,6 +260,7 @@ pub fn resultsLen(p: *Pardes) u64 { var n: u64 = 0; for (p.fs.results[0..p.fs.results_len]) |serial| { var digits: [16]u8 = undefined; + // unreachable: a u32 serial and a newline fit 16 n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len; } return n; @@ -446,7 +447,7 @@ pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { defer p.fs.no_prompt = false; // What a ctl write touched reads back from /look and /exec as a click's // does (`run`), so a script that made a pane with `Tty` finds it there. - const context = p.panes[id].?.serial; + const context = if (p.panes[id]) |pane| pane.serial else 0; const before = Serials.snapshot(p); _ = exec_line.executeBuiltinLine(p, id, line); const after = Serials.snapshot(p); @@ -580,7 +581,8 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { if (checkBuiltin(p, req, line, .session)) |refusal| return refusal; continue; } - if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); + // An empty window (no pane at all) still takes its session words. + if (p.panes[p.active] == null and p.anyPane() != null) return Reply.fail(req.tag, E.NOENT); p.fs.session_write = true; defer p.fs.session_write = false; if (runBuiltin(p, req, p.active, line)) |refusal| return refusal; @@ -1731,8 +1733,8 @@ test "Shell refuses a path that is no executable, and bare it goes back to the d const root_ctl = @intFromEnum(tree.TopFile.ctl); const refused = wr(p, root_ctl, "Shell /nonexistent/zzsh\n"); try testing.expectEqual(Status.err, refused.reply.status); - try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: no shell \"/nonexistent/zzsh\"") != null); - try testing.expect(th.logHas(p, "no shell \"/nonexistent/zzsh\"")); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: shell \"/nonexistent/zzsh\" not found") != null); + try testing.expect(th.logHas(p, "shell \"/nonexistent/zzsh\" not found")); try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno()); try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory")); try testing.expectEqualStrings("", p.settings.shell.requested.get()); @@ -1922,10 +1924,10 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir for (p.panes) |slot| count += @intFromBool(slot != null); const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, missed.errno()); - try testing.expectEqualStrings("grep: no match, not found", missed.reply.ename); + try testing.expectEqualStrings("grep: pattern not found", missed.reply.ename); const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, unfound.errno()); - try testing.expectEqualStrings("find: no match, not found", unfound.reply.ename); + try testing.expectEqualStrings("find: name not found", unfound.reply.ename); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); @@ -2096,7 +2098,7 @@ test "a language server question that finds nothing, cannot be answered, or is a p.lspRequest(id, .incoming_calls, ""); try testing.expect(p.lsp_wait != null); p.lspResponse(p.lsp_wait.?.id, ""); - try testing.expectEqualStrings("Callers: no callers, not found", p.fs.late_failure[0..p.fs.late_failure_len]); + try testing.expectEqualStrings("Callers: callers not found", p.fs.late_failure[0..p.fs.late_failure_len]); // A backend that cannot answer says why. p.fs.late_failure_len = 0; p.lspRequest(id, .outgoing_calls, ""); @@ -2140,6 +2142,23 @@ test "a corrupt image fails its look with an err, and no pane is made" { try testing.expectEqual(count, after); } +test "a truncated PDF fails its look with its path and why" { + if (comptime !pardes.pdf_enabled) return; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const bytes = try @import("mupdf").makeLinksTestPdf(testing.allocator); + defer testing.allocator.free(bytes); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "cut.pdf", .data = bytes[0..40] }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/cut.pdf\n", .{dir})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "cut.pdf: not a PDF pardes can read: ") != null); +} + test "a PDF's body reads its page's text layer, and takes no write" { if (comptime !pardes.pdf_enabled) return; const p = try withFile(testing.allocator, "x\n"); @@ -2201,6 +2220,20 @@ test "+Unsaved drops a pane once it has closed" { try testing.expect(std.mem.indexOf(u8, list.file.?.content, "/tmp/pardes-unsaved-a.txt: Modified") == null); } +test "a buffer whose file was deleted on disk is dirty, and Del asks first" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + pardes.panes.File.deleted(p, @intCast(p.paneBySerial(serial).?)); + try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + try testing.expectEqual(Status.err, wr(p, Node.of(serial, .ctl), "Del\n").reply.status); + try testing.expect(p.paneBySerial(serial) != null); + _ = wr(p, Node.of(serial, .ctl), "Del\n"); + p.sync(); + try testing.expect(p.paneBySerial(serial) == null); +} + test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2512,6 +2545,33 @@ test "Recent keeps only files on disk: not a name never saved, nor the served tr try testing.expect(p.recent.get(names[2]) != null); } +test "a new file saved shows in /recent, open, while it is" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + // A host that writes files, as the real ones do. + const Writing = struct { + fn write(_: ?*anyopaque, _: u8, path: []const u8, bytes: []const u8) void { + std.Io.Dir.cwd().writeFile(testing.io, .{ .sub_path = path, .data = bytes }) catch {}; + } + }; + p.host = .{ .vtable = &.{ .write_file = Writing.write } }; + defer p.host = .{}; + const made = try th.newPane(p); + var line: [4200]u8 = undefined; + const path = try std.fmt.bufPrint(&line, "{s}/new.txt", .{dir}); + var name: [4200]u8 = undefined; + _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path})); + _ = wr(p, Node.of(made, .body), "fresh\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Save\n").reply.status); + p.perform(.{ .save_file = .{ .pane = @intCast(p.paneBySerial(made).?) } }); + var want: [4300]u8 = undefined; + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{path})) != null); +} + test "/recent shows a name as /index does, a newline in it escaped" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2584,6 +2644,13 @@ test "a remembered place is clamped to the file as it is now, its selection's tw try testing.expect(back.body.vsel.active); // Its ends clamped to the last character, `two`'s `o`, not its newline. try testing.expect(back.body.cur_col <= 2 and back.body.vsel.col <= 2); + try testing.expect(std.mem.indexOf(u8, back.msg[0..back.msg_len], "c.txt has 2 lines now: at its last") != null); + // The same row with the file open: the same place, said the same way. + _ = wr(p, Node.of(rows.serial, .look), loc); + p.sync(); + try testing.expectEqual(back.serial, p.panes[p.active].?.serial); + try testing.expect(back.body.cur_row <= 1); + try testing.expect(std.mem.indexOf(u8, back.msg[0..back.msg_len], "at its last") != null); } test "size is monotonic: growing is never refused, and a size once taken is taken again" { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index f90a83d8..9f85a75f 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -125,7 +125,8 @@ pub fn tagOf(p: *Pardes, pane: *Pane) []const u8 { pub fn dirtyOf(pane: *const Pane) bool { const f = if (pane.file) |*x| x else return false; if (!panes.Output.fileTraits(f.output).saves) return false; - return f.revision != f.saved_revision; + // Its file deleted on disk: the text is only here now, as acme counts it. + return f.revision != f.saved_revision or f.disk_gone; } /// The pane's name: its file, its document, or a terminal's directory. @@ -280,6 +281,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { .pty_status => pty.status_len, .pty_ctl => pty_ctl: { var buf: [32]u8 = undefined; + // unreachable: two u16s and 9 bytes fit 32 break :pty_ctl (std.fmt.bufPrint(&buf, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch unreachable).len; }, .pty_data => if (pf.pty_out.peek()) |chunk| chunk.len else 0, @@ -303,9 +305,11 @@ pub fn indexLen(p: *Pardes) u64 { last = serial; const pane = p.panes[p.paneBySerial(serial).?].?; var digits: [16]u8 = undefined; + // 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; + // unreachable: a column index is under 16 digits n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } return n; @@ -665,7 +669,8 @@ fn nameFault(name: []const u8) ?[]const u8 { while (std.mem.indexOfScalarPos(u8, name, i, 0xC2)) |at| : (i = at + 1) if (at + 1 < name.len and name[at + 1] <= 0x9F) return e_name_char ++ ": a control character"; } - if (name[0] == ' ' or name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end"; + 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"; return null; } @@ -1123,6 +1128,24 @@ test "after a failed address, one that goes from it says there is none" { try testing.expectEqual(Status.ok, wr(p, addr, ".").reply.status); } +test "a write of two lines to name is refused EINVAL, on a held open or not" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const node = Node.of(serial, .name); + try testing.expectEqual(E.INVAL, wr(p, node, "/tmp/a\n/tmp/b\n").errno()); + 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).?].?)); + // 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).?].?)); +} + test "a name cut across writes is one name, applied once at its newline or its close" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -1252,7 +1275,7 @@ test "name reads the file name and writing it promotes a scratch without touchin // off), a second line, a control byte, DEL, a C1 control, not UTF-8. for ([_][2][]const u8{ .{ "trailing.zig \n", "a blank at its end" }, - .{ " leading.zig\n", "a blank at its end" }, + .{ " leading.zig\n", "a blank at its start" }, .{ "tab\t.zig\n", "a control character" }, .{ "two\nlines\n", "a newline" }, .{ "del\x7f.zig\n", "a control character" }, diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index bf5f432c..d32c35fd 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -183,14 +183,20 @@ pub const Run = struct { /// Plan 9's kprint read the same way). read: usize = 0, /// The first line: how it ended. - answer: [48]u8 = undefined, + answer: [96]u8 = undefined, len: u8 = 0, /// Then what the command printed, gpa-owned. output: []u8 = &.{}, }; fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void { - slot.len = @intCast((std.fmt.bufPrint(&slot.answer, fmt ++ "\n", args) catch unreachable).len); + // A program's name comes from the host, whatever its length: an answer + // longer than the room is cut, its newline kept, never a panic. + var w: std.Io.Writer = .fixed(&slot.answer); + w.print(fmt ++ "\n", args) catch { + slot.answer[slot.answer.len - 1] = '\n'; + }; + slot.len = @intCast(w.end); slot.phase = .done; p.fs.news = true; // the read held on it can be answered } @@ -362,6 +368,7 @@ fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void { } // A D that carries no status says nothing of how the command went. var code: [16]u8 = undefined; + // unreachable: an exit status fits 16 const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?"; // The header is the whole first line, so a count there can never be // mistaken for output; `cut` with no count: its start is not there to @@ -387,6 +394,7 @@ pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void { finish(p, slot, pane, code); }; var buf: [4]u8 = undefined; + // unreachable: a u8 exit code is at most 3 digits pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable); } @@ -1070,3 +1078,12 @@ test "the pty queue drops the oldest at its cap" { } try testing.expect(seen > 0 and seen <= events.queue_cap); } + +test "a run's answer longer than its room is cut, its newline kept" { + var slot: Run = .{}; + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + answer(p, &slot, "busy: {s} is running", .{"x" ** 200}); + try testing.expectEqual(slot.answer.len, slot.len); + try testing.expectEqual(@as(u8, '\n'), slot.answer[slot.len - 1]); +} diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 60778b9c..d6f4211b 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -162,6 +162,9 @@ pub const Open = struct { /// answered from it again: bash's `read` takes a chunk, keeps a line and /// lseeks back to just past it, and a stream has already moved on. replay: ?Replay = null, + /// A `name` open has named its pane: one name an open, so a second line + /// on it (bash writes `printf 'a\nb\n'` a line at a time) is refused. + named: bool = false, pub const Replay = struct { off: u64, bytes: []u8 }; @@ -512,6 +515,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .col => |c| { if (layout.columnBySerial(p, c.serial) == null) return null; return .{ + // unreachable: a u32 serial fits node_name (16) .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.serial}) catch unreachable) else c.file.fileName(), .node = Node.ofCol(c.serial, c.file), .dir = c.file == .dir, @@ -534,6 +538,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return null; return .{ + // unreachable: a u32 serial fits node_name (16) .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.fileName(), .node = Node.of(t.serial, t.file), .dir = t.file.isDir(), @@ -579,6 +584,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { if (p.header_focus) break :focus 0; const pn = p.panes[p.active] orelse break :focus 0; var digits: [16]u8 = undefined; + // unreachable: a u32 serial and a newline fit 16 break :focus (std.fmt.bufPrint(&digits, "{d}\n", .{pn.serial}) catch unreachable).len; }, }; @@ -688,6 +694,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { continue; } var buf: [16]u8 = undefined; + // unreachable: a u32 serial fits 16 const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); } @@ -699,6 +706,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { } var buf: [16]u8 = undefined; const serial = layout.columnSerial(p, c); + // unreachable: a u32 serial fits 16 stageDirent(out, p.gpa, Node.ofCol(serial, .dir), true, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable); }, else => return Reply.fail(req.tag, E.NOTDIR), @@ -1071,6 +1079,13 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { o.pending.clearRetainingCapacity(); return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB"); } + if (target == .pane and target.pane.file == .name) { + if (o.named or std.mem.count(u8, o.pending.items[0..end], "\n") > 1) { + o.pending.clearRetainingCapacity(); + return failText(req.tag, E.INVAL, "invalid file name: one name a write, on one line"); + } + o.named = true; + } const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM); const rest = o.pending.items.len - end; std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]); diff --git a/src/pardes.zig b/src/pardes.zig index ae04b4e7..dc479909 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1826,6 +1826,16 @@ test "a restored terminal's tag names neither the REPL it was nor the shell it h try std.testing.expect(std.mem.indexOf(u8, shown, "Tty+fish") == null); } +test "a Tty started by a shell's path keeps the path in its Tty+ word" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + try std.testing.expect(p.executeBuiltinLine(0, "Tty /bin/sh")); + try std.testing.expect(p.active != 0); + p.acknowledgeShell(p.active, "/bin/sh", false); + try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+/bin/sh ")); +} + test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -1843,7 +1853,7 @@ test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { const caller = p.panes[0].?; try std.testing.expect(p.executeBuiltinLine(0, "Tty /nonexistent")); try std.testing.expect(p.active == before and caller.shell == null); - try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null); + try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "shell \"/nonexistent\" not found") != null); try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); try std.testing.expect(caller.shell == null); // A directory passes access(X_OK); it is no shell either. @@ -3590,6 +3600,14 @@ pub const Key = struct { pub const page_up: u21 = 0xF0007; pub const page_down: u21 = 0xF0008; pub const delete: u21 = 0xF0009; + pub const insert: u21 = 0xF000A; + pub const print_screen: u21 = 0xF000B; + pub const scroll_lock: u21 = 0xF000C; + pub const pause: u21 = 0xF000D; + pub const menu: u21 = 0xF000E; + /// F1 to F25, in order: Fn is `f1 + n - 1`. + pub const f1: u21 = 0xF0011; + pub const f25: u21 = f1 + 24; }; pub fn hit(key: Key, binding: []const config.Chord) bool { @@ -4545,6 +4563,10 @@ pub const Pardes = struct { /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore <path>` last_dump: ?[]const u8 = null, + /// What a session word (Newcol, New, Exit) runs with when no pane is + /// there to run it: the window Delcol emptied. Never installed, made the + /// first time it is wanted. + stand_in: ?*Pane = null, /// a Restore builtin wants this dump loaded into the live session; the /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, @@ -4770,6 +4792,7 @@ pub const Pardes = struct { for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane); p.saveRecent(); p.recent.deinit(p.recentGpa()); + if (p.stand_in) |pane| p.teardownPane(pane); look.cancelLookHover(p); if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); @@ -5014,7 +5037,8 @@ pub const Pardes = struct { } fn newScratch(p: *Pardes, from_id: usize, free: usize, kind: exec.NewPane) !*Pane { - const src = p.panes[from_id] orelse return error.MissingPane; + // None asking (an empty window): the session's, as pane/new's is. + const src_or = p.panes[from_id]; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); // A terminal that has not said where it is yet (the boot layout's) @@ -5022,10 +5046,10 @@ pub const Pardes = struct { // 9P's pane/new, and a New from a column's or the workspace's tag: // no pane asked for it, whichever last had the keyboard (acme's new // window has acme's directory). - const own = kind != .fs and !p.exec_header; - const dir = if (own) paneDir(src) else ""; + const src = if (kind != .fs and !p.exec_header) src_or else null; + const dir = if (src) |s| paneDir(s) else ""; const np = try panes.Output.open(p, free, if (dir.len == 0) p.sessionDir() else dir, .{ .cmd = .New }, "", content); - if (own) np.cwd = .{ .inherited = src }; + if (src) |s| np.cwd = .{ .inherited = s }; np.body.cur_pinned = true; return np; } @@ -5046,6 +5070,8 @@ pub const Pardes = struct { /// Newcol: an empty column right of the calling pane's, the keyboard /// on its tag, for the next pane made to fill. pub fn newColumn(p: *Pardes, from_id: usize) void { + // An empty window (Delcol took the last column): its first column. + if (layout.firstColumn(p)) |c| return tagline.enterHeader(p, c); const col = p.keyboardColumn(from_id) orelse return; // Said, not silently nothing: "no space" is 9ns's word for ENOSPC. const c = layout.insertColumn(p, col, false) orelse { @@ -5179,7 +5205,9 @@ pub const Pardes = struct { // The tag's Tty word names the shell the host ran, and a click // on it (`Tty+fish`) opens another of the same. if (pane.command == null) { - const base = std.fs.path.basename(executable); + // A shell given as a path keeps it: its bare name would find + // nothing, or another program, on the usual paths. + const base = if (pane.shell) |given| (if (std.mem.indexOfScalar(u8, given, '/') != null) given else std.fs.path.basename(executable)) else std.fs.path.basename(executable); const rest = tagline.terminal_pane_tail[" Tty".len..]; if (std.fmt.bufPrint(&pane.shell_tail, " Tty+{s}{s}", .{ base, rest })) |tail| { pane.shell_tail_len = @intCast(tail.len); @@ -5319,6 +5347,12 @@ pub const Pardes = struct { } } + /// The pane a session word runs with in an empty window. + pub fn standIn(p: *Pardes) ?*Pane { + if (p.stand_in == null) p.stand_in = panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; + return p.stand_in; + } + /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied /// may be handed to a different pane next frame. fn dropPendingWrite(p: *Pardes, id: usize) void { @@ -5558,6 +5592,8 @@ pub const Pardes = struct { if (saved.saved_revision != saved.revision) return; saved.disk_gone = false; ctlfs.events.noteLog(p, .save, saved_pane); + // On disk now, a new file too: Recent has it, open. + if (saved.output == null and recentKeeps(saved.path)) p.recent.opened(p.recentGpa(), saved.path); if (!saved.watch_after_save) return; saved.watch_after_save = false; if (filesystem.localPath(saved.path) != null) @@ -6598,10 +6634,10 @@ pub const Pardes = struct { const none: ?[]const u8 = switch (w.kind) { .completion, .format, .rename => null, .diagnostics, .workspace_diagnostics => return p.setMessage(w.pane, std.fmt.bufPrint(&said, "{s}: none", .{lspWord(w.kind)}) catch "none"), - .incoming_calls => "no callers, not found", - .outgoing_calls => "no callees, not found", - .hover => "nothing to show, not found", - else => "nothing found, not found", + .incoming_calls => "callers not found", + .outgoing_calls => "callees not found", + .hover => "nothing to show: not found", + else => "not found", }; if (none) |text| p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: {s}", .{ lspWord(w.kind), text }) catch "nothing found"); return; @@ -6742,7 +6778,9 @@ pub const Pardes = struct { p.panes[closed] = null; } layout.dropColumn(p, col); - if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty(); + // The last column gone, the window stays, empty but for the + // workspace tag, as acme's does; Newcol starts again from there. + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return tagline.enterHeader(p, null); } /// No pane is left: the session is over. @@ -6784,7 +6822,7 @@ pub const Pardes = struct { } /// Any live pane: the last resort when nothing took the closed one's place. - fn anyPane(p: *const Pardes) ?usize { + pub fn anyPane(p: *const Pardes) ?usize { for (p.panes, 0..) |slot, k| if (slot != null) return k; return null; } @@ -6855,7 +6893,9 @@ pub const Pardes = struct { pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; - if (p.njumps == 0 or next < 0 or next >= p.njumps) return; + // At an end of the list: said, not a step that silently does nothing. + if (p.njumps == 0 or next < 0 or next >= p.njumps) + return p.setMessage(p.active, if (delta < 0) "Back: nothing further back" else "Forward: nothing further on"); const j = p.jumps[@intCast(next)]; if (j.pane >= MAX_PANES) return; // A closed file's entry: opened again at its dot, placed as a look @@ -8390,6 +8430,7 @@ test "jump history clears selections and lands at the recorded cursor" { const selected = pane.body.primaryRange(pane.file.?.content, 0); p.jumpBy(1); // No forward entry: selection stays untouched. try std.testing.expectEqualDeep(selected, pane.body.primaryRange(pane.file.?.content, 0)); + try std.testing.expect(std.mem.indexOf(u8, p.panes[p.active].?.msg[0..p.panes[p.active].?.msg_len], "nothing further on") != null); for ([_]Loc{ .{ .pane = 15, .serial = pane.serial, .line = 1, .col = 1 }, .{ .pane = 0, .serial = pane.serial + 1, .line = 1, .col = 1 }, @@ -8500,3 +8541,31 @@ test "a Save the host could not do logs no save record" { p.perform(.{ .save_file = .{ .pane = 0 } }); try std.testing.expect(th.logHas(p, "\nsave ")); } + +test "Delcol on the last column leaves the window, drawn and typed into, and Newcol starts it again" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + while (p.ncol > 0) { + p.exec_column = 0; + _ = p.executeBuiltinLine(p.active, "Delcol"); + p.exec_column = null; + p.sync(); + } + try std.testing.expect(!p.quit); + try std.testing.expect(p.anyPane() == null); + // Drawn, and keys go to the workspace tag. + _ = try p.render(p.frame_arena.allocator()); + p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + p.sync(); + _ = try p.render(p.frame_arena.allocator()); + try std.testing.expect(!p.quit); + _ = p.executeBuiltinLine(p.active, "Newcol"); + p.sync(); + try std.testing.expectEqual(@as(usize, 1), p.ncol); + _ = p.executeBuiltinLine(p.active, "New"); + p.sync(); + try std.testing.expect(p.anyPane() != null); + _ = try p.render(p.frame_arena.allocator()); +} diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 58dd2c3c..8f842cde 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2763,6 +2763,7 @@ pub const SectionRows = if (enabled) struct { for (entries, 0..) |entry, ordinal| { const resolved = ordinals[ordinal]; if (resolved == std.math.maxInt(usize)) continue; + // unreachable: resolveOrdinals resolves only to usable destinations const destination = usableDestination(entries[resolved].destination) orelse unreachable; total += switch (destination) { .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), @@ -2777,6 +2778,7 @@ pub const SectionRows = if (enabled) struct { for (entries, 0..) |entry, ordinal| { const resolved = ordinals[ordinal]; if (resolved == std.math.maxInt(usize)) continue; + // unreachable: resolveOrdinals resolves only to usable destinations const destination = usableDestination(entries[resolved].destination) orelse unreachable; const prefix = switch (destination) { .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), diff --git a/src/regexp.zig b/src/regexp.zig index 27b3c0ba..ca396ff1 100644 --- a/src/regexp.zig +++ b/src/regexp.zig @@ -48,6 +48,11 @@ pub const Regex = struct { /// ends of the whole text and so never match where sam would. pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex { if (pat.len == 0) return error.Bad; + // mvzr takes `^` only at its pattern's start, so `^def|^ ` (a `^` + // after a `|`) is written `^(def| )` for it: the same lines. A mix, + // `^a|b`, has no such spelling and is refused rather than wrong. + var joined: [258]u8 = undefined; + if (try anchoredAlternation(pat, &joined)) |whole| return compile(whole); var buf: [256]u8 = undefined; var len: usize = 0; var spans = false; @@ -97,6 +102,56 @@ 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: *[258]u8) error{Bad}!?[]const u8 { + var bars: [16]usize = undefined; + var n: usize = 0; + var depth: usize = 0; + var in_class = false; + var i: usize = 0; + while (i < pat.len) : (i += 1) { + const c = pat[i]; + if (c == '\\') { + i += 1; + } else if (in_class) { + in_class = c != ']'; + } else if (c == '[') { + in_class = true; + } else if (c == '(') { + depth += 1; + } else if (c == ')') { + depth -|= 1; + } else if (c == '|' and depth == 0) { + if (n == bars.len) return error.Bad; + bars[n] = i; + n += 1; + } + } + if (n == 0) return null; + var anchored: usize = 0; + var from: usize = 0; + for (0..n + 1) |k| { + const to = if (k < n) bars[k] else pat.len; + anchored += @intFromBool(to > from and pat[from] == '^'); + from = to + 1; + } + if (anchored == 0) return null; + if (anchored != n + 1) return error.Bad; + var w = std.Io.Writer.fixed(out); + w.writeAll("^(") catch return error.Bad; + from = 0; + for (0..n + 1) |k| { + const to = if (k < n) bars[k] else pat.len; + if (k > 0) w.writeByte('|') catch return error.Bad; + w.writeAll(pat[from + 1 .. to]) catch return error.Bad; + from = to + 1; + } + w.writeByte(')') catch return error.Bad; + return w.buffered(); + } + pub const Match = struct { start: usize, end: usize }; /// The first match that starts in `from..=last` and ends by `hi`, as @@ -185,6 +240,16 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li try std.testing.expectError(error.Bad, Regex.compile("a\\")); } +test "a ^ after | anchors that branch: ^def|^ finds a line that starts either way, and a mix is refused" { + const text = "x def\n a\ndef b\n"; + 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")); + var plain = try Regex.compile("a|b"); + try std.testing.expectEqual(@as(usize, 7), (try plain.find(text, 0, text.len, text.len)).?.start); +} + test "a quoted backslash before n is no newline, and a class ends where mvzr ends it" { // `\\n` is a backslash then an n: the pattern stays on one line, so its // `.` is not made [^\n] and does match within the line. diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index e1a42962..44cc1709 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -202,6 +202,14 @@ pub fn runOne( if (std.mem.indexOfScalar(u8, command, 0) != null or std.mem.indexOfScalar(u8, cwd, 0) != null) return fail.k(.spawn); + // std's spawn runs no code in the child: this thread's mask, which the + // child inherits, is cleared across the fork (only the tty's SIGWINCH is + // ever blocked, and its default is to be ignored). A handler resets at + // exec by itself, and nothing here is ignored. + const none = std.posix.sigemptyset(); + var kept: std.posix.sigset_t = undefined; + std.posix.sigprocmask(std.posix.SIG.SETMASK, &none, &kept); + defer std.posix.sigprocmask(std.posix.SIG.SETMASK, &kept, null); var child = std.process.spawn(io, .{ .argv = &.{ "/bin/sh", "-c", command }, .cwd = if (cwd.len == 0) .inherit else .{ .path = cwd }, diff --git a/src/tagline.zig b/src/tagline.zig index 5f95b2a0..df093925 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -30,7 +30,7 @@ const dirty_marker = " *"; /// A file with changes it has not saved. pub fn dirty(pane: *const panes.Pane) bool { const f = pane.file orelse return false; - return panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision; + return panes.Output.fileTraits(f.output).saves and (f.revision != f.saved_revision or f.disk_gone); } pub const legacy_pane_tail = " Del"; pub const legacy_file_pane_tail = " Save Del"; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index a6f6bdfc..01d2f08e 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -148,10 +148,26 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (scalar_len > end - (lead - 1)) parse_end = lead - 1; } var consumed: usize = 0; + var waited = false; while (consumed < parse_end) { + // A lone ESC that ends the read is the Escape key, or the first + // byte of a sequence whose rest the read boundary held back (an + // ssh packet, a busy link), which parsed now left the rest to be + // typed as text. Wait a moment for more. Under the kitty keyboard + // protocol Escape is `CSI 27 u`, so there the ESC can only start + // a sequence and the wait can be long. + const rest = buf[consumed..parse_end]; + if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { + waited = true; + if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; + } const result = try parser.parse(buf[consumed..parse_end], loop.vaxis.opts.system_clipboard_allocator); if (result.n == 0) break; consumed += result.n; + // A modifier pressed on its own, which the kitty keyboard + // protocol reports: pardes binds none, it would break a pending + // chord, and a shell pane was typed its private-use code. + if (result.event) |event| if (event == .key_press and event.key_press.isModifier()) continue; if (result.event) |event| vaxis.loop.handleEventGeneric(loop, loop.vaxis, cache, @TypeOf(Command.value), event, loop.vaxis.opts.system_clipboard_allocator) catch |err| { if (event == .paste) if (loop.vaxis.opts.system_clipboard_allocator) |gpa| gpa.free(@constCast(event.paste)); @@ -163,6 +179,21 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } } +/// Whether more input is readable within `ms`, polled in short slices so +/// that a cancel of the input thread is still seen. A test reader answers +/// for itself. +fn inputFollows(tty: anytype, io: std.Io, ms: u32) !bool { + if (@hasDecl(@TypeOf(tty.*), "inputFollows")) return tty.inputFollows(ms); + const handle = if (@TypeOf(tty.fd) == std.Io.File) tty.fd.handle else tty.fd; + var fds = [_]posix.pollfd{.{ .fd = handle, .events = posix.POLL.IN, .revents = 0 }}; + var left = ms; + while (left > 0) : (left -|= 10) { + try io.checkCancel(); + if (try posix.poll(&fds, @intCast(@min(left, 10))) > 0) return true; + } + return false; +} + test "terminal input preserves fragmented keys queries paste and text after EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -183,6 +214,9 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } fn read(self: *@This(), buf: []u8) !usize { if (self.next == self.parts.len) return 0; const part = self.parts[self.next]; @@ -243,6 +277,184 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" try std.testing.expect(resized and up and pasted and clipboard and escape); } +test "terminal replies and malformed sequences never become typed text" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ + .parts = &.{ + // The startup width queries' answers where the terminal has no + // explicit width: both used to arrive as a bare F3, typed as text. + "a\x1b[1;1R\x1b[1;1R", + // A late report somewhere else on the screen, and DECXCPR. + "\x1b[12;40R\x1b[?3;7;1R", + // rxvt's Alt+Up: ESC before the sequence, not Alt+Esc then `[A`. + "b\x1b\x1b[A", + // A kitty graphics reply cut inside its ST sliced past its input. + "\x1b_Gi=1;OK\x1b", + "\\", + // An X10 mouse report cut after `CSI M` left three bytes as text. + "\x1b[M", + " !!", + // Malformed color and clipboard replies failed the parse, which + // ended input altogether; and a reply ended by BEL ran on to the + // next sequence's ESC, leaving what lay between as text. + "\x1b]11;rgb:zz/zz/zz\x1b\\\x1b]52;c;!!!!\x07\x1b]52\x07", + // Device reports whose fields end early. + "\x1b[?1;y\x1b[?997;n\x1b[c", + "\x1b[?62;c", + "c", + }, + }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(vx.queries_done.load(.unordered)); + try std.testing.expect(vx.caps.kitty_graphics); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + var alt_up = false; + var clicked = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => {}, + .key_press => |key| { + if (key.codepoint == vaxis.Key.up and key.mods.alt) { + alt_up = true; + } else if (key.text) |bytes| { + try text.appendSlice(gpa, bytes); + } else return error.UnexpectedInputKey; + }, + .mouse => |mouse| { + try std.testing.expectEqual(@as(i16, 0), mouse.col); + clicked = true; + }, + else => return error.UnexpectedInputEvent, + }; + try std.testing.expectEqualStrings("abc", text.items); + try std.testing.expect(alt_up and clicked); +} + +test "terminal input split at every byte with a gap reads as it does whole" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + // Two reads with `gap_ms` between them, as an ssh link delivers a + // sequence cut across two packets. + const Reader = struct { + parts: [2][]const u8, + gap_ms: u32, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), ms: u32) bool { + return self.next < self.parts.len and self.gap_ms <= ms; + } + fn read(self: *@This(), buf: []u8) !usize { + while (self.next < self.parts.len) { + const part = self.parts[self.next]; + self.next += 1; + if (part.len == 0) continue; + @memcpy(buf[0..part.len], part); + return part.len; + } + return 0; + } + /// What the loop was handed, one line per event. + fn events(l: *Loop, out: *std.ArrayList(u8)) !void { + out.clearRetainingCapacity(); + while (try l.tryEvent()) |event| switch (event) { + .winsize => {}, + .key_press => |k| try out.print(std.testing.allocator, "key {d} {d} {?s}\n", .{ k.codepoint, @as(u8, @bitCast(k.mods)), k.text }), + .mouse => |m| try out.print(std.testing.allocator, "mouse {d} {d} {t} {t}\n", .{ m.col, m.row, m.button, m.type }), + .paste => |bytes| { + try out.print(std.testing.allocator, "paste {s}\n", .{bytes}); + std.testing.allocator.free(@constCast(bytes)); + }, + else => |other| try out.print(std.testing.allocator, "{t}\n", .{other}), + }; + } + }; + const Case = struct { bytes: []const u8, kitty: bool = false }; + const cases = [_]Case{ + .{ .bytes = "\x1b[118;5u", .kitty = true }, // Ctrl-V + .{ .bytes = "\x1b[97;;97u", .kitty = true }, // a, with its text + .{ .bytes = "\x1b[57442;5u", .kitty = true }, // Ctrl on its own + .{ .bytes = "\x1b[27u", .kitty = true }, // Escape + .{ .bytes = "\x1b[A" }, + .{ .bytes = "\x1bOA" }, + .{ .bytes = "\x1b[3;5~" }, + .{ .bytes = "\x1b\x1b[A" }, + // Four wheel notches: cut at a `;` with 60 ms between the halves, + // ssh's framing lost all four. + .{ .bytes = "\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M" }, + .{ .bytes = "\x1b[200~pasted\x1b[201~" }, + .{ .bytes = "\x1b]52;c;Y2xpcA==\x1b\\" }, + .{ .bytes = "\x1b_Gi=1;OK\x1b\\" }, + .{ .bytes = "\x1bP>|kitty(0.40)\x1b\\" }, + }; + var whole: std.ArrayList(u8) = .empty; + defer whole.deinit(gpa); + var split: std.ArrayList(u8) = .empty; + defer split.deinit(gpa); + for (cases) |case| { + vx.caps.kitty_keyboard = case.kitty; + const all = try std.mem.concat(gpa, u8, &.{ "x", case.bytes, "y" }); + defer gpa.free(all); + var one: Reader = .{ .parts = .{ all, "" }, .gap_ms = 0 }; + try readInput(&loop, &one, &cache); + try Reader.events(&loop, &whole); + for (1..all.len) |cut| { + // A cut just after an ESC is the one that has to wait, and a + // legacy terminal's Escape waits only 50 ms: 40 ms there. A cut + // anywhere else is carried however long the gap. + const after_esc = all[cut - 1] == 0x1b; + var two: Reader = .{ .parts = .{ all[0..cut], all[cut..] }, .gap_ms = if (after_esc and !case.kitty) 40 else 60 }; + try readInput(&loop, &two, &cache); + try Reader.events(&loop, &split); + std.testing.expectEqualStrings(whole.items, split.items) catch |err| { + std.debug.print("case {f} cut at {d}\n", .{ std.ascii.hexEscape(case.bytes, .lower), cut }); + return err; + }; + } + } + vx.caps.kitty_keyboard = false; +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -262,6 +474,9 @@ test "terminal input cancellation joins blocked reads and queued EOF" { fn getWinsize(_: *@This()) !vaxis.Winsize { return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; } + fn inputFollows(_: *@This(), _: u32) bool { + return false; + } fn read(self: *@This(), buf: []u8) !usize { self.entered.set(std.testing.io); if (self.eof) { @@ -1726,6 +1941,24 @@ fn mapKey(cp: u21) u21 { vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, + vaxis.Key.insert => pardes.Key.insert, + vaxis.Key.print_screen => pardes.Key.print_screen, + vaxis.Key.scroll_lock => pardes.Key.scroll_lock, + vaxis.Key.pause => pardes.Key.pause, + vaxis.Key.menu => pardes.Key.menu, + vaxis.Key.f1...vaxis.Key.f25 => pardes.Key.f1 + (cp - vaxis.Key.f1), + // The keypad's own keys, which the kitty keyboard protocol tells + // apart, do what the keys they are named for do. + vaxis.Key.kp_left => pardes.Key.left, + vaxis.Key.kp_right => pardes.Key.right, + vaxis.Key.kp_up => pardes.Key.up, + vaxis.Key.kp_down => pardes.Key.down, + vaxis.Key.kp_page_up => pardes.Key.page_up, + vaxis.Key.kp_page_down => pardes.Key.page_down, + vaxis.Key.kp_home => pardes.Key.home, + vaxis.Key.kp_end => pardes.Key.end, + vaxis.Key.kp_insert => pardes.Key.insert, + vaxis.Key.kp_delete => pardes.Key.delete, else => cp, }; } @@ -1751,6 +1984,69 @@ fn keyEvent(key: vaxis.Key) pardes.Event { } }; } +test "a terminal pane gets function keys as their sequences and lone modifiers not at all" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ + .parts = &.{ + // The startup width queries' answers from a terminal without + // explicit width (foot, ghostty, tmux): two F3s, typed at the prompt. + "\x1b[1;1R\x1b[1;1R\x1b[?62;c", + // Kitty reports Ctrl and Shift pressed on their own: the Ctrl of a + // Ctrl-V typed `\x1b[57442;5u` into the shell. + "\x1b[57442;5u\x1b[57441;2u\x1b[57358u", + // F3, F5 and Insert the kitty way, F3 the legacy way and the + // keypad's Up: once typed as private-use junk, now what htop, + // mc and vim read them as. + "\x1b[13~\x1b[15~\x1b[2~\x1bOR\x1b[57419u", + "\x1b[104;;104u", + }, + }; + try readInput(&loop, &reader, &cache); + const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); + defer core.deinit(); + core.panes[0].?.body.mode = .tty; + while (core.nextEffect()) |_| {} + while (try loop.tryEvent()) |event| switch (event) { + .key_press => |key| core.update(keyEvent(key)), + else => {}, + }; + var written: std.Io.Writer.Allocating = .init(gpa); + defer written.deinit(); + while (core.nextEffect()) |effect| { + if (effect == .write) try written.writer.writeAll(effect.write.bytes.slice()); + } + try std.testing.expectEqualStrings("\x1bOR\x1b[15~\x1b[2~\x1bOR\x1b[Ah", written.written()); +} + test "keypad Enter matches Return for editor input terminal writes and paste" { for ([_]u21{ vaxis.Key.enter, vaxis.Key.kp_enter }) |codepoint| { const key: vaxis.Key = .{ .codepoint = codepoint }; @@ -622,11 +622,11 @@ def workflow_canary(binary): rows = client.read(f'/pane/{grep}/body').decode() assert 'a.txt:2' in rows and 'b.txt:2' in rows, rows client.remove(f'/pane/{grep}') - execute_refused(client, a, 'Grep zzqqnomatch', 'grep: no match') + execute_refused(client, a, 'Grep zzqqnomatch', 'grep: pattern not found') found = made_by(a, b'Find b.txt\n') assert 'b.txt' in client.read(f'/pane/{found}/body').decode() client.remove(f'/pane/{found}') - execute_refused(client, a, 'Find zzqqnomatch', 'find: no match') + execute_refused(client, a, 'Find zzqqnomatch', 'find: name not found') # A plain word looked at is found in the pane, after its dot. client.write(f'/pane/{a}/dot', b' 0 0 ') @@ -814,6 +814,56 @@ def signal_cleans_up(binary): assert not address.exists() and not detached.exists(), (sig, list(root.iterdir())) +def odd_paths(binary): + """Long and odd paths written where a path goes -- name, look, DumpDir, + Dump, Save -- are refused or said, never a crash: a part over 255 bytes + (ENAMETOOLONG), a loop of links (ELOOP), a file taken for a directory + (ENOTDIR), one not ours (EACCES).""" + with tempfile.TemporaryDirectory(prefix='pardes-odd-') as directory: + root = Path(directory) + (root / 'loop').symlink_to(root / 'loop') + (root / 'plain').write_bytes(b'x\n') + odd = ['/tmp/' + 'x' * 300 + '/f.txt', str(root / ('y' * 300)), str(root / 'loop' / 'f'), + str(root / 'plain' / 'f'), '/root/nope/f', str(root / 'a' / '..' / 'b.txt')] + with session(binary, root, 'odd') as (client, _): + serial = new_pane(client, b'text\n') + for path in odd: + for where, line in ((f'/pane/{serial}/name', path), ('/look', path), + ('/ctl', f'DumpDir {path}'), ('/ctl', 'Dump'), (f'/pane/{serial}/ctl', 'Save')): + try: + client.write(where, (line + '\n').encode()) + except OSError: + pass + assert client.read('/index'), (where, line) + client.write('/ctl', b'DumpDir\n') + # A theme file that is not there, or no theme, fails the write. + (root / 'bad.zon').write_bytes(b'.{ .nonsense = 1 }') + for line, why in ((f'ThemeFile {root}/missing.zon', 'theme file'), (f'ThemeFile {root}/bad.zon', 'theme file'), + ('ThemeFile notes.txt', 'a theme file is a .zon')): + try: + client.write('/ctl', (line + '\n').encode()) + raise AssertionError(f'{line} was taken') + except OSError as refused: + assert why in str(refused), (line, refused) + + +def delcol_last_keeps_window(binary): + """Delcol on the last column leaves the window, empty but for the + workspace tag, as acme does; Newcol and New start again from there.""" + with tempfile.TemporaryDirectory(prefix='pardes-delcol-') as directory: + with session(binary, Path(directory), 'delcol') as (client, _): + def columns(): + return [l for l in client.read('/layout').decode().splitlines() if l and not l.startswith('active')] + while columns(): + client.write(f'/col/{columns()[0].split()[0]}/ctl', b'Delcol\n') + # Still here, empty. + assert client.read('/index') == b'', client.read('/index') + client.write('/ctl', b'Newcol\n') + assert len(columns()) == 1, columns() + serial = new_pane(client, b'back\n') + assert str(serial).encode() in client.read('/index'), client.read('/index') + + def cut_writes(binary): """A write bigger than a 9P message comes as several, cut anywhere: each command line runs once, whole, an Edit block once it ends, and a last @@ -846,6 +896,8 @@ def cut_writes(binary): def test(binary, quic=False): started = time.monotonic() cut_writes(binary) + odd_paths(binary) + delcol_last_keeps_window(binary) run_file(binary) exit_answers(binary) unattended_messages_expire(binary) diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json index fd64ebf9..6bfe2c90 100644 --- a/test/perf-baseline-gui-Debug.json +++ b/test/perf-baseline-gui-Debug.json @@ -1 +1 @@ -{"harness":"67f85bf9692f1778","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":1768,"med_us":2025,"p90_us":2122,"max_us":2125},{"op":"render","fixture":"medium","min_us":72,"med_us":75,"p90_us":76,"max_us":83},{"op":"key-down","fixture":"medium","min_us":73,"med_us":76,"p90_us":80,"max_us":84},{"op":"key-right","fixture":"medium","min_us":74,"med_us":77,"p90_us":81,"max_us":84},{"op":"page-down","fixture":"medium","min_us":73,"med_us":74,"p90_us":77,"max_us":79},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":81,"max_us":81},{"op":"edit-char","fixture":"medium","min_us":2413,"med_us":2674,"p90_us":2943,"max_us":3111}]} +{"harness":"67f85bf9692f1778","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":1887,"med_us":2147,"p90_us":2277,"max_us":2326},{"op":"render","fixture":"medium","min_us":73,"med_us":76,"p90_us":84,"max_us":96},{"op":"key-down","fixture":"medium","min_us":73,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":91,"max_us":173},{"op":"page-down","fixture":"medium","min_us":75,"med_us":78,"p90_us":80,"max_us":84},{"op":"wheel","fixture":"medium","min_us":72,"med_us":76,"p90_us":79,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1269,"med_us":1437,"p90_us":1680,"max_us":1697}]} diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json index 682ea028..6ffd034c 100644 --- a/test/perf-baseline-tty-Debug.json +++ b/test/perf-baseline-tty-Debug.json @@ -1 +1 @@ -{"harness":"67f85bf9692f1778","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":1944,"med_us":2130,"p90_us":2232,"max_us":2394},{"op":"render","fixture":"medium","min_us":71,"med_us":75,"p90_us":76,"max_us":91},{"op":"key-down","fixture":"medium","min_us":72,"med_us":79,"p90_us":93,"max_us":102},{"op":"key-right","fixture":"medium","min_us":73,"med_us":76,"p90_us":78,"max_us":81},{"op":"page-down","fixture":"medium","min_us":72,"med_us":73,"p90_us":76,"max_us":76},{"op":"wheel","fixture":"medium","min_us":71,"med_us":85,"p90_us":96,"max_us":102},{"op":"edit-char","fixture":"medium","min_us":2612,"med_us":3112,"p90_us":3238,"max_us":3618}]} +{"harness":"67f85bf9692f1778","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":1907,"med_us":2037,"p90_us":2283,"max_us":2371},{"op":"render","fixture":"medium","min_us":72,"med_us":80,"p90_us":87,"max_us":87},{"op":"key-down","fixture":"medium","min_us":73,"med_us":78,"p90_us":82,"max_us":88},{"op":"key-right","fixture":"medium","min_us":72,"med_us":75,"p90_us":87,"max_us":87},{"op":"page-down","fixture":"medium","min_us":73,"med_us":81,"p90_us":93,"max_us":94},{"op":"wheel","fixture":"medium","min_us":73,"med_us":75,"p90_us":84,"max_us":88},{"op":"edit-char","fixture":"medium","min_us":1333,"med_us":1712,"p90_us":1956,"max_us":2636}]} diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json index 4fd96d92..5d68e6b2 100644 --- a/test/perf-baseline-tty-ReleaseFast.json +++ b/test/perf-baseline-tty-ReleaseFast.json @@ -1 +1 @@ -{"harness":"67f85bf9692f1778","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":1971,"med_us":2259,"p90_us":2308,"max_us":2339},{"op":"render","fixture":"medium","min_us":71,"med_us":75,"p90_us":78,"max_us":79},{"op":"key-down","fixture":"medium","min_us":73,"med_us":77,"p90_us":79,"max_us":95},{"op":"key-right","fixture":"medium","min_us":72,"med_us":77,"p90_us":80,"max_us":88},{"op":"page-down","fixture":"medium","min_us":72,"med_us":76,"p90_us":81,"max_us":83},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":80,"max_us":91},{"op":"edit-char","fixture":"medium","min_us":2662,"med_us":2999,"p90_us":3375,"max_us":3622}]} +{"harness":"67f85bf9692f1778","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":1771,"med_us":2022,"p90_us":2188,"max_us":2228},{"op":"render","fixture":"medium","min_us":75,"med_us":76,"p90_us":76,"max_us":86},{"op":"key-down","fixture":"medium","min_us":73,"med_us":79,"p90_us":80,"max_us":80},{"op":"key-right","fixture":"medium","min_us":72,"med_us":78,"p90_us":81,"max_us":82},{"op":"page-down","fixture":"medium","min_us":72,"med_us":76,"p90_us":80,"max_us":84},{"op":"wheel","fixture":"medium","min_us":75,"med_us":76,"p90_us":78,"max_us":79},{"op":"edit-char","fixture":"medium","min_us":1106,"med_us":1436,"p90_us":1692,"max_us":1721}]} diff --git a/test/snapshots/lookloaded.golden b/test/snapshots/lookloaded.golden index 6644a430..70b42102 100644 --- a/test/snapshots/lookloaded.golden +++ b/test/snapshots/lookloaded.golden @@ -22,7 +22,7 @@ | big.txt == snap removed grid=100x31 cursor=54,8 |6: rm big.txt; echo GO''NE -|7: /tmp/pardes-snap/lookloaded/cwd/big.txt Save Tt GONE +|7: * /tmp/pardes-snap/lookloaded/cwd/big.txt Save Tt GONE == snap look-noFS grid=100x31 cursor=14,13 |8: y Collapse Del big.txt:10 |9: 6 line 6 diff --git a/test/snapshots/lsp.golden b/test/snapshots/lsp.golden index 2d3880f1..be0090fb 100644 --- a/test/snapshots/lsp.golden +++ b/test/snapshots/lsp.golden @@ -33,4 +33,4 @@ == snap step grid=120x41 cursor=15,3 |3: 1 const std = @import("std"); == snap nothing grid=120x41 cursor=7,5 -|3: 1 const std = @import("std"); definition: nothing found, not found +|3: 1 const std = @import("std"); definition: not found diff --git a/test/snapshots/shellset.golden b/test/snapshots/shellset.golden index db72bfc8..0d13f017 100644 --- a/test/snapshots/shellset.golden +++ b/test/snapshots/shellset.golden @@ -4,7 +4,7 @@ | /tmp/pardes-snap/shellset/cwd/cmd.txt Save Tty Collapse Del | 1 Shell zznosuchshell == snap builtin-ran grid=90x21 cursor=25,3 -|3: hell: no shell "zznosuchshell", not found (a name on the usual paths, or a path to one) +|3: Shell: shell "zznosuchshell" not found (a name on the usual paths, or a path to one) == snap fallback-shell grid=90x21 cursor=49,6 |1: New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol |2: /tmp/pardes-snap/shellset/cwd/cmd.txt Save /tmp/pardes-snap/shellset/cwd Tty+bash Sav |
