diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 53 | ||||
| -rw-r--r-- | src/Output.zig | 5 | ||||
| -rw-r--r-- | src/config.zig | 19 | ||||
| -rw-r--r-- | src/crash.zig | 2 | ||||
| -rw-r--r-- | src/detached/server.zig | 13 | ||||
| -rw-r--r-- | src/exec.zig | 30 | ||||
| -rw-r--r-- | src/fs.zig | 8 | ||||
| -rw-r--r-- | src/host_io.zig | 95 | ||||
| -rw-r--r-- | src/look.zig | 134 | ||||
| -rw-r--r-- | src/main.zig | 132 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 4 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 133 | ||||
| -rw-r--r-- | src/ninep/events.zig | 79 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 24 | ||||
| -rw-r--r-- | src/panes.zig | 4 | ||||
| -rw-r--r-- | src/pardes.zig | 16 | ||||
| -rw-r--r-- | src/tagline.zig | 2 |
17 files changed, 646 insertions, 107 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index 9406ceab..24b0c9fb 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog -## 0.0.15 +## 0.16 + +- Releases are numbered 0.N from here on; this changelog's earlier 0.0.N + headings read 0.N. +- `pardes -` reads stdin into a `+Pager` pane, and a terminal's `PAGER` and + `GIT_PAGER` are `pardes -` unless your environment sets them: `git log` + and `man` open as panes, and a paging command through `pty/run` no longer + waits. Command panes page through `cat`. `Pager off` keeps your own pager. +- A command run from a pane gets `$winid`, that pane's serial, as acme's do. +- A PDF page taller than a raster is drawn at the size it is shown, in bands + around the screen, instead of being squeezed into 4096 rows and enlarged: + tall pages are sharp, and memory stays bounded. +- A diff line's look, whether clicked, written to `look` or written back + through `event`, goes through one expansion; a blank context line works. +- `log` stats 0 and 9ns opens files of length 0 as streams, so `cat` follows + the log through a mount. +- A second `--detach=NAME` while NAME runs says so. `/index` escapes control + bytes. `file:0:0` is refused. An event reader of a closed pane reads EOF. + `+Unsaved` lands in a directory that exists. `pardes NEW` refuses a + directory it may not write. +- Docs: a "Setting up your environment" chapter (9ns, the editor and pager, + yazi, shells, language servers, fonts, plan9port) after the guide. + +## 0.15 - The documentation is one Typst corpus in `docs/typ/`: a guide (a short walkthrough: modes, buttons, tags, the active column, looks, terminals, @@ -15,7 +38,7 @@ filled, keys as one pill per press (`Ctrl+w`, `g›g`), and its pairs as tables. -## 0.0.14 +## 0.14 - A pane's `tagexec` reaches an event reader holding the pane as an `x` record, as a click in its tag does. @@ -37,7 +60,7 @@ list's), `event` takes one reader, and bash's `printf` writes a mounted ctl one line at a time. -## 0.0.13 +## 0.13 - A one-page A4 cheatsheet, built from Typst sources in `docs/typ/` (`zig build cheatsheet`); a test fails when it names a word, key or tag that no longer exists. - A look on a diff line is the look of the `path:line` it names, exactly as @@ -46,7 +69,7 @@ hand-selected path would, and the diff's own path finding and its messages are gone. -## 0.0.12 +## 0.12 - A selection deep in a very long line no longer slows every frame: a 5 MB line with the selection at its end redraws in milliseconds, not seconds. @@ -71,7 +94,7 @@ program as its buttons 2 and 3. B1 and the wheel stay the program's, and Shift keeps them pardes's. -## 0.0.11 +## 0.11 - A look on a diff names a file as the repository does: git's `a/`/`b/` (or mnemonic `c/`, `i/`, `w/`) is dropped only where both paths of the @@ -79,7 +102,7 @@ plain `diff -u`'s names are as written, and a missing file is said by that name (`Look: src/x.zig: no such file here`). -## 0.0.10 +## 0.10 - A 9P write never moves dot's end: scrolling written text into view moves only the view, and dot shifts as acme's textinsert shifts it, so a `sel` @@ -96,7 +119,7 @@ - A terminal's word looks share one `+Search` per directory. `Save path` logs its `save`. Restore logs the absolute path it read. -## 0.0.9 +## 0.9 - A terminal's program that tracks the mouse gets its clicks, drags and motion as well as its wheel, in the format it asked for; hold Shift for @@ -119,7 +142,7 @@ bytes) keeps its cut last line for the next write, so `seq … > exec` runs every line once. -## 0.0.8 +## 0.8 - `~` and `~/` mean the home directory wherever a path is taken: look (`~/x:12`, `~/x:/re/`), `name`, `Save`, `ThemeFile`, DumpDir, Restore. @@ -149,7 +172,7 @@ is refused. `Kill X` with nothing running is a message, not a failure. An event write-back needs no trailing newline. -## 0.0.7 +## 0.7 - `lapis` draws its cells unblended and each plaque pixel once: about a third less GPU time a frame at 4K, pixel for pixel the same. `lapis_plain` @@ -175,7 +198,7 @@ - `Lspwhy` explains the last language-server query, whatever its kind. - Bulk body writes over 9P are about 15x faster per 60 KB write. -## 0.0.6 +## 0.6 - A middle or right sweep in a column tag or the workspace tag runs or looks up the swept text on release, highlighted while it is held, as in a pane's @@ -201,7 +224,7 @@ - The GUI always embeds its shader sources; the prebuilt GLSL copies are gone. `lspprobe` is `zig build lspbench -- probe`. -## 0.0.5 +## 0.5 - Over a slow link the terminal build keeps at most one frame in flight: it asks the terminal for a status report after each frame and draws the @@ -225,7 +248,7 @@ - The docs and the 9P skill are shorter, with one home per fact. `zig build monkey-gui` runs the GUI monkey. -## 0.0.4 +## 0.4 - Drive the editor over 9P the way acme is driven. Panes take sam addresses (`/re/`, `#n`, `$-1`, `0/re/`), `L:C` and inclusive `L:C-L2:C2` @@ -343,7 +366,7 @@ with `-Dembed-sources=true`. The tree code moved from `src/fs.zig` to `src/ninep/`. -## 0.0.3 +## 0.3 - Add pinned tree-sitter declaration context, with optional compact tagline styling and separators between discontinuous source lines. @@ -366,7 +389,7 @@ - Detached sessions now use protocol version 6. Update the server and attached clients together. -## 0.0.2 +## 0.2 - A panic is written down somewhere it survives. Every crash this program has ever had went to stderr and nowhere else, and stderr is the one place it @@ -532,7 +555,7 @@ curated three the floor refuses 12, 16 and 7 of 256 keys, where a WCAG body-text 4.5 would refuse 61, 154 and 91 and flatten the palette. -## 0.0.1 +## 0.1 - Pane taglines sit where they do in the SDL window. The macOS shell had its own copy of the band geometry and it only ever centred the shorter band in its diff --git a/src/Output.zig b/src/Output.zig index 4aa0c021..1160c455 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -656,7 +656,10 @@ pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void { return; } const pane = p.panes[id] orelse return p.gpa.free(content); - const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const own = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + // Its directory gone (or never there, a name not yet saved): the list + // goes in the session's, where a look from it can still resolve. + const dir = if (comptime pardes.hosted) (if (pardes.filesystem.localPath(own)) |local| (if (exec.isDirectory(local)) own else p.sessionDir()) else own) else own; const free = p.freeSlot() orelse return p.gpa.free(content); _ = open(p, free, dir, .unsaved, "", content) catch return; exec.placeNew(p, id, id, free, .doc); diff --git a/src/config.zig b/src/config.zig index 7908774b..5a9cf070 100644 --- a/src/config.zig +++ b/src/config.zig @@ -17,7 +17,9 @@ pub const Chord = struct { shift: bool = false, }; -// Leader paths apply in body normal mode, not tags or raw terminals. +// Leader paths apply wherever normal mode reads keys: a body's, a tag's +// (column and workspace tags too) and a PDF's, never a raw terminal's +// (normal.zig takes `.leader` for every target). pub const leader: []const Chord = &.{.{ .cp = ' ' }}; pub const leader_help: u8 = '?'; @@ -79,6 +81,7 @@ pub const leader_path = paths: { .Placement = "cp", .BootShell = null, .LookWord = null, + .Pager = null, .Debug = "td", .Msg = null, .Colors = "tc", @@ -752,13 +755,18 @@ pub const Runtime = struct { /// next place in that pane, wrapping; `list`, what pardes did before, /// lists every place in a +Search pane (look.zig, lookWord). look_word: LookWord = .search, + /// What a terminal's shell pages through (host_io.ChildEnv): `pardes`, + /// PAGER and GIT_PAGER set to `pardes -`, a +Pager pane, where the + /// user's environment set neither; `off`, the environment as it is. + pager: Pager = .pardes, pub const ShaderAnimation = enum { off, on, always }; pub const Placement = enum { acme, pardes }; pub const BootShell = enum { keep, replace }; pub const LookWord = enum { search, list }; + pub const Pager = enum { pardes, off }; /// A setting that is one of a few words, each an enum field of Runtime. - pub const Choice = enum { placement, boot_shell, look_word }; + pub const Choice = enum { placement, boot_shell, look_word, pager }; /// shadow: a soft drop shadow. rim: a light hairline along the top of /// the focused pane's tag rows. auto: a shadow on a light page; on a @@ -998,7 +1006,7 @@ pub const Runtime = struct { .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve }, .doc = "How long, in milliseconds, a message takes to dissolve." }, .{ .word = "DumpDir", .action = .dump_dir, .doc = "The directory Dump writes to and a relative Restore reads from; bare, the default." }, .{ .word = "Theme", .action = .theme, .doc = "Switch to the named theme." }, - .{ .word = "Shell", .action = .shell, .doc = "The shell new terminals run; bare, the login shell." }, + .{ .word = "Shell", .action = .shell, .doc = "The shell new terminals run; bare, $SHELL if it is executable, else /bin/sh." }, .{ .word = "Font", .action = .font, .availability = .font_picker, .doc = "The body font, by name or path, and its size." }, .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker, .doc = "Scale the tag font, a percentage." }, .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity, .doc = "The window's opacity, a percentage." }, @@ -1023,6 +1031,7 @@ pub const Runtime = struct { .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." }, .{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." }, .{ .word = "LookWord", .action = .{ .choice = .look_word }, .doc = "A plain word looked at: search selects its next place in the pane, list lists every place in a +Search." }, + .{ .word = "Pager", .action = .{ .choice = .pager }, .doc = "What a terminal's commands page through: pardes, a +Pager pane (pardes -), or off, the environment's own; for terminals started after it." }, }; pub fn find(name: []const u8) ?Setting { @@ -1250,7 +1259,7 @@ pub const Runtime = struct { .grip_width => "50-300 (a percentage)", .tagline_size => std.fmt.comptimePrint("{d}-{d} (a percentage)", .{ tagline_percent_min, tagline_percent_max }), .message_ms => std.fmt.comptimePrint("0-{d} (milliseconds)", .{message_ms_max}), - .shell => "a path of at most 255 bytes; bare, the login shell", + .shell => "a path of at most 255 bytes; bare, $SHELL, else /bin/sh", .dump_dir => std.fmt.comptimePrint("a path of at most {d} bytes; bare, the default", .{limits.host_path_cap}), .shader => "a Shadertoy file's path, or off", .font => "a name or path, :8 to :72 after it (Mono:14)", @@ -1697,6 +1706,7 @@ pub const Runtime = struct { .placement => "pardes", .boot_shell => "replace", .look_word => "list", + .pager => "off", }, .lift => "rim", .motion => "playful", @@ -1766,6 +1776,7 @@ pub const Runtime = struct { .placement => "Placement pardes\n", .boot_shell => "BootShell replace\n", .look_word => "LookWord list\n", + .pager => "Pager off\n", }, .lift => "Lift rim\n", .motion => "Motion playful\n", diff --git a/src/crash.zig b/src/crash.zig index 99822aad..34dc1b3d 100644 --- a/src/crash.zig +++ b/src/crash.zig @@ -47,7 +47,7 @@ pub fn setDir(path: []const u8) void { /// Built at comptime out of the two halves main.zig's `--version` prints, and /// for the same reason: there is nothing here to format at runtime. A build -/// from a tarball says `pardes 0.0.1` rather than inventing a revision. +/// from a tarball says `pardes 0.1` rather than inventing a revision. const build_id = if (pardes.commit) |c| "pardes " ++ pardes.version ++ " (" ++ c ++ ")" else diff --git a/src/detached/server.zig b/src/detached/server.zig index 78b85836..588912bb 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1238,6 +1238,17 @@ pub const detached_cols = 160; pub const detached_rows = 50; pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void { + // A name a live session holds: said, exit 1, before anything is made + // (no error trace, no second session racing the first for the socket). + if (comptime supported) { + var path_buf: [path_max]u8 = undefined; + if (sessionPath(&path_buf, name)) |path| if (ninep_io.statNoFollow(path) != null and alive(path)) { + var said: [path_max + 64]u8 = undefined; + const text = std.fmt.bufPrint(&said, "pardes: a session named {s} is already running\n", .{name}) catch "pardes: a session of that name is already running\n"; + std.Io.File.stderr().writeStreamingAll(init.io, text) catch {}; + std.process.exit(1); + }; + } const gpa = init.gpa; const allocs = pardes.memory.init(gpa); defer pardes.memory.deinit(); @@ -1282,7 +1293,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void try session.initAsync(); if (!session.listen(name)) { try std.Io.File.stderr().writeStreamingAll(init.io, "pardes: could not bind a detached session socket\n"); - return error.NoSocket; + std.process.exit(1); } session.ninep = ninep_io.listen(init.io, gpa, core, opts.ninep_name, name, opts.ninep_tcp, opts.ninep_quic); diff --git a/src/exec.zig b/src/exec.zig index 104e3b4d..47484105 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -176,21 +176,14 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand // the code's, the prefix never one of them. var word_from: usize = 0; if (clicked.r0 >= pane.tag_rows and clicked.r0 == clicked.r1 and clicked.c0 == clicked.c1 and row >= 0) diff: { - const lines = look.diffLines(p, pane) orelse break :diff; - const at: usize = @intCast(row); - if (at >= lines.len) break :diff; - const line = lines[at]; var address_buf: [4096 + 32]u8 = undefined; - const address = look.diffAddress(pane, lines, at, &address_buf) orelse break :diff; - const target = @import("diff.zig").targetAt(lines, at, pane.file == null).?; - const on_prefix = col == 0 and line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null; - switch (target.kind) { - .context, .added, .removed => if (!on_prefix) { - word_from = @intFromBool(line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null); - break :diff; - }, - else => {}, - } + const at: usize = @intCast(row); + const click = look.diffClick(p, pane, at, @intCast(@max(0, col)), &address_buf) orelse break :diff; + const address = click.address orelse { + word_from = click.word_from; + break :diff; + }; + const line = (look.diffLines(p, pane) orelse break :diff)[at]; result.text = p.scratch.allocator().dupe(u8, address) catch break :diff; const hi = std.math.cast(i32, line.len) orelse return result; if (hi == 0) return result; @@ -799,6 +792,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { if (panes.terminal.onAlternateScreen(pane)) panes.terminal.feedOutput(p, pane, "\x1b[?1049l"); panes.terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); echoCommand(p, pane, line); + pane.command_from = winid(p, src); p.emitSpawn(id, pane.serial, pane.cwdSlice()); noteRun(p, pane, "run", line); return id; @@ -811,12 +805,20 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { p.reportError(from, "exec", err); return null; }; + pane.command_from = winid(p, src); echoCommand(p, pane, line); placeNew(p, from, from, free, .command); noteRun(p, pane, "run", line); return free; } +/// The serial a command's `$winid` names: the pane it was run from, none +/// (0) from a column's or the workspace's tag. +fn winid(p: *const Pardes, src: ?*Pane) u32 { + if (p.exec_header) return 0; + return if (src) |s| s.serial else 0; +} + /// `% line` above a command's output, every command, as a shell echoes /// what it was given; its newlines the terminal's, so a multi-line command /// does not staircase. @@ -1360,7 +1360,13 @@ fn readFileLimit(gpa: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 { defer _ = libc.close(fd); const end = libc.lseek(fd, 0, libc.SEEK.END); - if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile; + // Not seekable: a pipe or a FIFO is no file; a regular file that is a + // stream (a 9ns mount's log or screen, opened nonseekable) is read to + // its end, as one with no length is. + if (end < 0 and libc.errno(end) == .SPIPE) { + const facts = ninep_io.statNoFollow(path_z) orelse return error.NotAFile; + if (facts.mode & 0o170000 != 0o100000) return error.NotAFile; + } const size: usize = if (end < 0) 0 else @intCast(end); if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed; if (size == 0) { diff --git a/src/host_io.zig b/src/host_io.zig index ebe910aa..4ebac9ee 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1255,7 +1255,12 @@ pub const ChildEnv = struct { return line[0 .. std.mem.indexOfScalar(u8, line, '=') orelse line.len]; } - pub fn build(slots: *Slots) ?[*:null]const ?[*:0]const u8 { + /// Which pager a child is given where the user's environment names none: + /// a command pane's one-shot shell `cat` (its output is a pane already), + /// a terminal's shell `pardes -` (a +Pager pane) unless `Pager off`. + pub const Paging = enum { command, terminal, terminal_off }; + + pub fn build(slots: *Slots, paging: Paging) ?[*:null]const ?[*:0]const u8 { var n: usize = 0; var i: usize = 0; while (libc.environ[i]) |entry| : (i += 1) { @@ -1268,6 +1273,24 @@ pub const ChildEnv = struct { slots[n] = entry; n += 1; } + // No pager to wait in (less holds a run at its first screen for + // ever): a command pane's output is read whole, through cat; a + // terminal's goes to a +Pager pane. Only where the inherited + // environment chose none. + const defaults: []const [*:0]const u8 = switch (paging) { + .command => &cat_pagers, + .terminal => pardesPagers(), + .terminal_off => &.{}, + }; + for (defaults) |default| { + const set = for (libc.environ[0..i]) |entry| { + if (std.mem.eql(u8, name(entry.?), name(default))) break true; + } else false; + if (set) continue; + if (n + own.len + 2 > slots.len) return null; + slots[n] = default; + n += 1; + } for (own) |mine| { slots[n] = mine; n += 1; @@ -1275,6 +1298,28 @@ pub const ChildEnv = struct { slots[n] = null; return @ptrCast(slots); } + + const cat_pagers = [_][*:0]const u8{ "PAGER=cat", "GIT_PAGER=cat" }; + + /// `PAGER='<this pardes> -'`, its own path so no PATH lookup can find + /// another; plain `pardes -` where the path cannot be read or quoted. + fn pardesPagers() []const [*:0]const u8 { + const S = struct { + var pager: [std.fs.max_path_bytes + 16:0]u8 = undefined; + var git_pager: [std.fs.max_path_bytes + 16:0]u8 = undefined; + var entries: [2][*:0]const u8 = undefined; + var ready = false; + }; + if (!S.ready) { + var exe_buf: [std.fs.max_path_bytes]u8 = undefined; + const n = if (comptime builtin.os.tag == .linux) libc.readlink("/proc/self/exe", &exe_buf, exe_buf.len) else -1; + const exe: []const u8 = if (n > 0 and @as(usize, @intCast(n)) < exe_buf.len and std.mem.indexOfScalar(u8, exe_buf[0..@intCast(n)], '\'') == null) exe_buf[0..@intCast(n)] else "pardes"; + S.entries[0] = if (std.fmt.bufPrintSentinel(&S.pager, "PAGER='{s}' -", .{exe}, 0)) |t| t.ptr else |_| "PAGER=pardes -"; + S.entries[1] = if (std.fmt.bufPrintSentinel(&S.git_pager, "GIT_PAGER='{s}' -", .{exe}, 0)) |t| t.ptr else |_| "GIT_PAGER=pardes -"; + S.ready = true; + } + return &S.entries; + } }; test "the child environment replaces the launcher's terminal identity exactly once" { @@ -1299,11 +1344,32 @@ test "the child environment replaces the launcher's terminal identity exactly on defer _ = unsetenv("PARDES_CHILD_ENV_PROBE"); var slots: ChildEnv.Slots = undefined; - const envp = ChildEnv.build(&slots) orelse return error.EnvironmentTooLarge; + // Where the user named no pager: a terminal's shell pages into a + // +Pager pane (`pardes -`), or, Pager off, as the environment says; a + // command pane's through cat. A pager the user named is kept. + for ([_]ChildEnv.Paging{ .terminal, .terminal_off }) |paging| { + var shell_slots: ChildEnv.Slots = undefined; + const shell_envp = ChildEnv.build(&shell_slots, paging) orelse return error.EnvironmentTooLarge; + var said: usize = 0; + var j: usize = 0; + while (shell_envp[j]) |entry| : (j += 1) { + const line = std.mem.span(entry); + if (std.mem.startsWith(u8, line, "PAGER=") and std.c.getenv("PAGER") == null) { + try std.testing.expect(paging == .terminal and std.mem.endsWith(u8, line, " -")); + said += 1; + } + } + if (std.c.getenv("PAGER") == null) try std.testing.expectEqual(@as(usize, if (paging == .terminal) 1 else 0), said); + } + const envp = ChildEnv.build(&slots, .command) orelse return error.EnvironmentTooLarge; var terms: usize = 0; var colorterms: usize = 0; var probes: usize = 0; var dumb = false; + var pagers: usize = 0; + var git_pagers: usize = 0; + var pager_cat = false; + var git_pager_cat = false; var i: usize = 0; while (envp[i]) |entry| : (i += 1) { const line = std.mem.span(entry); @@ -1311,7 +1377,16 @@ test "the child environment replaces the launcher's terminal identity exactly on if (std.mem.startsWith(u8, line, "COLORTERM=")) colorterms += 1; if (std.mem.eql(u8, line, "TERM=dumb")) dumb = true; if (std.mem.eql(u8, line, "PARDES_CHILD_ENV_PROBE=1")) probes += 1; + if (std.mem.startsWith(u8, line, "PAGER=")) pagers += 1; + if (std.mem.startsWith(u8, line, "GIT_PAGER=")) git_pagers += 1; + if (std.mem.eql(u8, line, "PAGER=cat")) pager_cat = true; + if (std.mem.eql(u8, line, "GIT_PAGER=cat")) git_pager_cat = true; } + // A pager only where the user named none: cat, so nothing waits in less. + try std.testing.expectEqual(@as(usize, 1), pagers); + try std.testing.expectEqual(@as(usize, 1), git_pagers); + if (std.c.getenv("PAGER") == null) try std.testing.expect(pager_cat); + if (std.c.getenv("GIT_PAGER") == null) try std.testing.expect(git_pager_cat); try std.testing.expectEqual(@as(usize, 1), terms); try std.testing.expectEqual(@as(usize, 1), colorterms); try std.testing.expectEqual(@as(usize, 1), probes); @@ -1323,6 +1398,14 @@ test "the child environment replaces the launcher's terminal identity exactly on extern "c" fn unsetenv(name: [*:0]const u8) c_int; +fn exportWinid(serial: u32) void { + var buf: [16]u8 = undefined; + if (serial != 0) if (std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0)) |text| { + if (Shell.setenv("winid", text, 1) == 0) return; + } else |_| {}; + _ = unsetenv("winid"); +} + /// The longest line a command pane runs (exec.zig refuses a longer one). pub const exec_command_max = pardes.exec.command_max; @@ -1390,6 +1473,9 @@ pub fn forkShell( if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, if (core) |c| !c.opts.nested else false, ); + // acme's `$winid`: the pane a command was run from, so a script it runs + // can address that pane; unset for a shell and a header tag's command. + exportWinid(if (core) |c| (if (c.panes[pane]) |pn| (if (pn.command != null) pn.command_from else 0) else 0) else 0); if (helper) |path| { const c = core.?; const pn = c.panes[pane].?; @@ -1403,7 +1489,10 @@ pub fn forkShell( // not call setenv, whose malloc can deadlock against a pty reader thread // that held the heap when the fork took its snapshot. var env_slots: ChildEnv.Slots = undefined; - const envp = ChildEnv.build(&env_slots); + const paging: ChildEnv.Paging = if (one_line != null) + .command + else if (core) |c| (if (c.settings.pager == .off) .terminal_off else .terminal) else .terminal; + const envp = ChildEnv.build(&env_slots, paging); const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; // The child says here why it could not become the shell (its chdir or // its exec failed, as for a script whose interpreter is not there): the diff --git a/src/look.zig b/src/look.zig index 4d23582b..922504c0 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1238,6 +1238,15 @@ test "a miss names a long path by its start and its end, its middle given up to try std.testing.expectEqualStrings("short/path.txt", missText("short/path.txt", &cut)); } +/// A file that is there but would not open: said with its path, `look: +/// <path>: permission denied`, the path cut in its middle when long. +fn lookOpenFailed(p: *Pardes, id: usize, path: []const u8, err: anyerror) void { + if (@import("Messages.zig").dialReason(err) != null) return p.reportError(id, path, err); + var cut: [miss_path_cap + "…".len]u8 = undefined; + var said: [miss_path_cap + 16]u8 = undefined; + p.reportError(id, std.fmt.bufPrint(&said, "look: {s}", .{missText(path, &cut)}) catch "look", err); +} + fn missedPage(p: *Pardes, id: usize, path: []const u8, page: usize) void { var cut: [miss_path_cap + "…".len]u8 = undefined; missed(p, id, "{s} has no page {d}", .{ missText(path, &cut), page }); @@ -1262,6 +1271,24 @@ fn zeroColumn(rest: []const u8) bool { return false; } +/// Whether `text` ends in a `:line:col` place (each spelling zeroColumn +/// knows) whose column is 0, line 0 included. +fn zeroColumnTail(text: []const u8) bool { + var i: usize = 0; + while (std.mem.indexOfScalarPos(u8, text, i, ':')) |at| : (i = at + 1) { + const rest = text[at..]; + for (rest[1..]) |c| if (std.mem.indexOfScalar(u8, "0123456789:-", c) == null) break else { + if (rest.len > 1 and std.ascii.isDigit(rest[1]) and zeroColumn(rest)) return true; + }; + } + return false; +} + +test "a zero column ends a look's text, line 0 included" { + for ([_][]const u8{ "f.txt:0:0", "f.txt:3:0", "dir/f.txt:3:2-0" }) |t| try std.testing.expect(zeroColumnTail(t)); + for ([_][]const u8{ "f.txt:3", "f.txt:3:1", "f.txt:0", "f.txt", "a:b:0x" }) |t| try std.testing.expect(!zeroColumnTail(t)); +} + test "a zero column after a path is found, in every spelling a look takes" { for ([_][]const u8{ ":3:0", ":3:0-4", ":3:2-0", ":3:2-5:0" }) |rest| try std.testing.expect(zeroColumn(rest)); for ([_][]const u8{ "", ":3", ":3:1", ":3:2-5", ":3:2-5:7", ":30:10" }) |rest| try std.testing.expect(!zeroColumn(rest)); @@ -1567,16 +1594,45 @@ pub fn diffAddress(pane: *const Pane, lines: []const []const u8, row: usize, buf return target.address(buf); } +/// What a click at byte `col` of line `row` of a diff pane takes, the one +/// answer for every way a click arrives (a mouse click, a 9P look of the +/// whole line, an empty look or exec written back to `event`): a file or +/// hunk header anywhere, or a hunk line on its `+`/`-`/` ` prefix, is the +/// address the line names (diffAddress); a hunk line's code is words, from +/// `word_from` on, the prefix never one of them. Null when the pane shows +/// no diff or the line is no part of one. +pub const DiffClick = struct { address: ?[]const u8 = null, word_from: usize = 0 }; + +pub fn diffClick(p: *Pardes, pane: *Pane, row: usize, col: usize, buf: []u8) ?DiffClick { + const lines = diffLines(p, pane) orelse return null; + return diffClickIn(pane, lines, row, col, buf); +} + +fn diffClickIn(pane: *const Pane, lines: []const []const u8, row: usize, col: usize, buf: []u8) ?DiffClick { + if (row >= lines.len) return null; + const line = lines[row]; + const address = diffAddress(pane, lines, row, buf) orelse return null; + const target = diff.targetAt(lines, row, pane.file == null) orelse return null; + const prefixed = line.len > 0 and std.mem.indexOfScalar(u8, "+- ", line[0]) != null; + switch (target.kind) { + .context, .added, .removed => if (!(col == 0 and prefixed)) return .{ .word_from = @intFromBool(prefixed) }, + else => {}, + } + return .{ .address = address }; +} + /// A look written over 9P of a whole line of a diff pane (the cursor's line -/// first, then the first after it): its address, as a click there makes. +/// first, then the first after it): what a click on its first column takes. +/// A blank context line (` `) is a line too. fn diffLineAddress(p: *Pardes, pane: *Pane, operand: []const u8, buf: []u8) ?[]const u8 { const text = std.mem.trimEnd(u8, operand, "\r\n"); - if (std.mem.trim(u8, text, " \t").len == 0) return null; + if (text.len == 0) return null; const lines = diffLines(p, pane) orelse return null; const cur: usize = @intCast(@max(0, pane.body.cur_row)); for (0..lines.len) |k| { const i = (cur + k) % lines.len; - if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) return diffAddress(pane, lines, i, buf); + if (std.mem.eql(u8, std.mem.trimEnd(u8, lines[i], "\r"), text)) + return (diffClickIn(pane, lines, i, 0, buf) orelse return null).address; } return null; } @@ -1616,6 +1672,10 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { const trimmed = std.mem.trim(u8, txt, " \t\r\n"); // acme's `file:addr` and `:addr` (look.c:450-543): an address after // the colon, evaluated in that file from its dot (openfile, look.c:675). + // `file:3:0`, and `file:0:0` too: columns count from 1, as addr's do, + // and a zero is refused in addr's words rather than taken as no column + // (or, after line 0, as an address). + if (zeroColumnTail(trimmed)) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero); if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr); // `:N` on a PDF is its page N, never a search for the text. if (comptime pdf_enabled) if (pane.hasPdf() and trimmed.len > 1 and trimmed[0] == ':') @@ -1623,7 +1683,6 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { const pl = parsePathLine(trimmed); // `file:3:0`: columns count from 1, as addr's do, and a zero is refused // in addr's words rather than taken as no column. - if (pl.at.line != 0 and zeroColumn(trimmed[pl.path.len..])) return p.reportFailure(id, @import("ninep/addr.zig").e_col_zero); if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane); if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; var realbuf: [4096]u8 = undefined; @@ -1711,7 +1770,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { if (!focusPaneByPath(p, target.path, target.at)) { const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| - return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); + return lookOpenFailed(p, id, target.path, err); if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1); const failures = p.fs.failures; exec.placeNew(p, id, id, free, .doc); @@ -1733,7 +1792,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { if (focusPaneByPath(p, target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const made = panes.Image.create(p, free, target.path, &.{}) catch |err| - return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err); + return lookOpenFailed(p, id, target.path, err); // One it cannot read (corrupt, truncated, not what its name says) // is said, a failure, rather than shown as a blank pane. if (!@import("image.zig").readable(made.image.?.raw)) { @@ -2230,6 +2289,69 @@ test "Look on a diff: headers and a hunk line's prefix are the whole line, hover } } +test "a click, a 9P whole-line look and an empty event write-back take one expansion on every kind of diff line, a blank context line too" { + if (!platform_has_fs) return; + const diff_text = + \\diff --git a/src/new.zig b/src/new.zig + \\--- a/src/new.zig + \\+++ b/src/new.zig + \\@@ -2,3 +2,3 @@ fn x() void { + \\ two + \\-drei + \\+three + \\ + \\ + ; + var fx: DiffFixture = undefined; + try fx.init(true, &.{.{ "src/new.zig", "one\ntwo\nthree\n\nfive\n" }}, diff_text); + defer fx.tmp.cleanup(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try fx.open(p, diff_text); + pane.tag_expanded = false; + const Point = struct { row: u16, col: u16, want: []const u8, whole_line: bool = true }; + const points = [_]Point{ + .{ .row = 0, .col = 0, .want = "src/new.zig" }, // diff --git + .{ .row = 1, .col = 0, .want = "src/new.zig" }, // --- + .{ .row = 2, .col = 0, .want = "src/new.zig" }, // +++ + .{ .row = 3, .col = 0, .want = "src/new.zig:2" }, // @@ + .{ .row = 4, .col = 0, .want = "src/new.zig:2" }, // a context line's ` ` + .{ .row = 5, .col = 0, .want = "src/new.zig:3" }, // `-` + .{ .row = 6, .col = 0, .want = "src/new.zig:3" }, // `+` + .{ .row = 7, .col = 0, .want = "src/new.zig:4" }, // a blank context line + .{ .row = 6, .col = 3, .want = "three", .whole_line = false }, // code: a word + }; + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for (points) |at| { + p.active = 0; + pane.file.?.scroll = 0; + pane.body.cur_row = 0; + p.sync(); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + // The mouse's. + const rect = p.rects[0]; + const x = rect.x + config.GUTTER + panes.File.gutterWidth(pane) + at.col; + const y = p.bodyTop(pane, rect) + at.row; + const pointed = edit.pointerTextSelection(p, 0, x, y, null, null) orelse return error.NothingPointed; + const clicked = exec.pointerOperand(p, pane, pointed.sel).text orelse return error.NothingClicked; + try std.testing.expectEqualStrings(at.want, clicked); + // An empty record written back to event, at the same byte. + const body = pane.file.?.content; + const line_start = modal.lineStartOffset(body, at.row); + const written = pardes.ctlfs.events.expandAt(p, pane, body, line_start + at.col, true); + try std.testing.expectEqualStrings(at.want, written); + // A 9P look of the whole line. + if (at.whole_line) { + const lines = diffLines(p, pane).?; + var buf: [4096 + 32]u8 = undefined; + const whole = diffLineAddress(p, pane, lines[at.row], &buf) orelse return error.NoWholeLineAddress; + try std.testing.expectEqualStrings(at.want, whole); + } + } +} + test "Look outside a diff pane expands and looks as ever" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); defer p.deinit(); diff --git a/src/main.zig b/src/main.zig index 3218fef1..8818645e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -46,11 +46,13 @@ pub const debug = if (is_emscripten) struct {} else struct { }; const help_text = - \\Usage: pardes [options] [file|dir] + \\Usage: pardes [options] [file|dir|-] \\ \\With no arguments the shell pane starts in tty mode. A FILE argument \\(optionally file:LINE) opens it alone, filling the window; a DIRECTORY - \\argument becomes the cwd shells spawn in. + \\argument becomes the cwd shells spawn in. `-` reads stdin, its terminal + \\escapes stripped, into a +Pager pane: inside a pardes pane the outer + \\session's, returning at once (a terminal's PAGER), else a new editor's. \\ \\Options: \\ --tty start as one shell pane already in tty mode @@ -98,6 +100,95 @@ const help_text = \\ ; +/// Stdin to its end, its terminal escapes stripped (a pager's input is +/// coloured for a terminal; a text pane shows text), at most a file's +/// limit, a longer one cut there and said so at its end. +fn readStdinText(gpa: std.mem.Allocator) ![]u8 { + const limit = pardes.memory.limits.max_file_bytes; + var raw: std.ArrayList(u8) = .empty; + var chunk: [64 * 1024]u8 = undefined; + var cut = false; + while (true) { + const n = std.c.read(0, &chunk, chunk.len); + if (n < 0) { + if (std.posix.errno(n) == .INTR) continue; + return error.ReadFailed; + } + if (n == 0) break; + const got: usize = @intCast(n); + if (raw.items.len + got > limit) { + try raw.appendSlice(gpa, chunk[0 .. limit - raw.items.len]); + cut = true; + break; + } + try raw.appendSlice(gpa, chunk[0..got]); + } + var text = try stripEscapes(gpa, raw.items); + if (cut) { + const said = "\n[pardes: the rest of stdin is cut: a pane holds at most a file's limit]\n"; + text = try std.mem.concat(gpa, u8, &.{ text, said }); + } + return text; +} + +/// `bytes` without terminal escapes: CSI (`ESC [` ... a final byte, SGR +/// colour among them), OSC (`ESC ]` ... BEL or `ESC \`), any other +/// two-byte `ESC x`, and a man page's overstrike (`x BS x`, `_ BS x`). +pub fn stripEscapes(gpa: std.mem.Allocator, bytes: []const u8) ![]u8 { + var out = try std.ArrayList(u8).initCapacity(gpa, bytes.len); + var i: usize = 0; + while (i < bytes.len) { + const c = bytes[i]; + if (c == 0x1b and i + 1 < bytes.len) { + const kind = bytes[i + 1]; + i += 2; + if (kind == '[') { + while (i < bytes.len and !(bytes[i] >= 0x40 and bytes[i] <= 0x7e)) i += 1; + i += 1; + } else if (std.mem.indexOfScalar(u8, "()*+", kind) != null) { + i += 1; // a charset's name after it + } else if (kind == ']') { + while (i < bytes.len) : (i += 1) { + if (bytes[i] == 0x07) { + i += 1; + break; + } + if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '\\') { + i += 2; + break; + } + } + } + continue; + } + // Overstrike: the byte before a backspace is struck over. + if (c == 0x08) { + if (out.items.len > 0) _ = out.pop(); + i += 1; + continue; + } + out.appendAssumeCapacity(c); + i += 1; + } + return out.toOwnedSlice(gpa); +} + +test "stripEscapes takes out colour, other escapes and overstrike, and keeps the text" { + const gpa = std.testing.allocator; + const cases = [_][2][]const u8{ + .{ "\x1b[33mcommit abc\x1b[m\n", "commit abc\n" }, + .{ "a\x1b]8;;http://x\x1b\\link\x1b]8;;\x07b", "alinkb" }, + .{ "N\x08NA\x08AM\x08ME\x08E _\x08u", "NAME u" }, + .{ "plain\ttext\n", "plain\ttext\n" }, + .{ "\x1b(Bdone", "done" }, + }; + for (cases) |c| { + const got = try stripEscapes(gpa, c[0]); + defer gpa.free(got); + try std.testing.expectEqualStrings(c[1], got); + } +} + /// A forwarding failure in words, never an error's name: the ones this /// file raises said outright, any other as its name's words (`NotFound`, /// `not found`), a dial's as Messages says it. @@ -106,6 +197,7 @@ fn forwardWords(err: anyerror, buf: []u8) []const u8 { error.NotOneLine => "a file name is one line, and this one holds a newline", error.NotAFileName => "names a directory, not a file", error.NoWorkingDirectory => "this shell's working directory is gone", + error.DirectoryNotWritable => "its directory may not be read or written, so it could never be saved", error.NotAPaneAddress => "not a pane address: @p and a pane's number", error.NoSuchPane => "this session has no such pane", else => if (pardes.Messages.dialReason(err)) |why| why else words: { @@ -368,7 +460,8 @@ fn nativeMain(init: std.process.Init) !void { } else if (std.mem.eql(u8, a, "--version")) { try std.Io.File.stdout().writeStreamingAll(init.io, version_text); return; - } else if (a.len > 0 and a[0] != '-' and positional == null) { + } else if (a.len > 0 and (a[0] != '-' or std.mem.eql(u8, a, "-")) and positional == null) { + // `-` is stdin, as for cat and vim: a text to show. session_only = false; positional = a; } else if (a.len == 0) { @@ -439,6 +532,24 @@ fn nativeMain(init: std.process.Init) !void { // itself, which is what a right click in that pane would have been. var look_buf: [64]u8 = undefined; const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial}); + // `pardes -`: stdin read to its end, a +Pager pane of the session + // made of it (as acme's `B` has no stdin, through pane/new, a name + // and the body), and back at once: the PAGER a pane's shell gets. + if (positional) |word| if (std.mem.eql(u8, word, "-")) { + const text = readStdinText(arena) catch |err| Refuse.with(init.io, "stdin", err); + const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch |err| Refuse.with(init.io, "-", err); + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch |err| Refuse.with(init.io, "-", err); + var cwd_buf: [4096]u8 = undefined; + const cwd = if (std.c.getcwd(&cwd_buf, cwd_buf.len)) |c| std.mem.span(@as([*:0]u8, @ptrCast(c))) else "/"; + var path_buf: [64]u8 = undefined; + var line_buf: [4200]u8 = undefined; + const name_line = std.fmt.bufPrint(&line_buf, "{s}/+Pager\n", .{std.mem.trimEnd(u8, cwd, "/")}) catch "/+Pager\n"; + ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/name", .{serial}), name_line) catch |err| Refuse.with(init.io, "-", err); + if (text.len > 0) ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/body", .{serial}), text) catch |err| Refuse.with(init.io, "-", err); + // Shown, not edited: nothing to ask about at Exit. + ninep_io.Client.write(arena, parent.dial, try std.fmt.bufPrint(&path_buf, "/pane/{d}/ctl", .{serial}), "clean\n") catch {}; + return; + }; const typed_word = positional orelse { var tag_buf: [64]u8 = undefined; const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); @@ -483,6 +594,15 @@ fn nativeMain(init: std.process.Init) !void { const cwd = std.mem.sliceTo(&cwd_buf, 0); break :named std.fs.path.resolvePosix(arena, &.{ cwd, target.path }) catch |err| Refuse.with(init.io, word, err); }; + // A directory that is there but may not be read or written: a + // pane for the name could only fail at its Save, so refused now. + var dir_z_buf: [4096]u8 = undefined; + if (std.fmt.bufPrintSentinel(&dir_z_buf, "{s}", .{dir.path}, 0)) |dir_z| { + const R_OK = 4; + const W_OK = 2; + const X_OK = 1; + if (std.c.access(dir_z.ptr, R_OK | W_OK | X_OK) != 0) Refuse.with(init.io, word, error.DirectoryNotWritable); + } else |_| {} break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch |err| Refuse.with(init.io, word, err); }; var made_serial: ?u32 = null; @@ -520,6 +640,12 @@ fn nativeMain(init: std.process.Init) !void { if (wait) waitForDel(init.io, arena, parent.dial, path, made_serial); return; } + // `pardes -` outside a session: an editor whose first pane holds stdin, + // as `vim -` does; the terminal is /dev/tty, never stdin. + if (positional) |a| if (std.mem.eql(u8, a, "-")) { + opts.stdin_text = readStdinText(arena) catch |err| badArgs(init.io, "stdin: {s}", .{@errorName(err)}); + positional = null; + }; if (positional) |a| { const target = @import("look.zig").parsePathLine(a); if (std.mem.eql(u8, target.path, "/n") or std.mem.startsWith(u8, target.path, "/n/") or diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig index b2e62d28..e3b456ba 100644 --- a/src/ninep/cols.zig +++ b/src/ninep/cols.zig @@ -167,10 +167,10 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { } else false; // Plan 9's words for it, so a mount's errno is EINVAL as the root's // and a pane's ctl give (9ns reads "control message"). - if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{line[0..@min(line.len, 64)]}) catch "unknown control message"); + if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{pardes.ctlfs.ctl.firstWord(line)[0..@min(pardes.ctlfs.ctl.firstWord(line).len, 64)]}) catch "unknown control message"); // Only Tty takes a word (its shell); `New extra` is no New. if (!std.mem.eql(u8, word, "Tty") and word.len != line.len) - return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{line[0..@min(line.len, 64)]}) catch "wrong #args in control message"); + return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{pardes.ctlfs.ctl.firstWord(line)[0..@min(pardes.ctlfs.ctl.firstWord(line).len, 64)]}) catch "wrong #args in control message"); const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT); p.exec_column = col; defer p.exec_column = null; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 31265a94..87339b70 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -296,7 +296,7 @@ pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, i /// The errno a builtin's failure answers with: ENOENT for what is not /// there (`no such`, `not found`, the words 9ns reads as ENOENT too), else /// EIO. -fn failureErrno(failure: []const u8) u16 { +pub fn failureErrno(failure: []const u8) u16 { if (std.mem.indexOf(u8, failure, "no such") != null or std.mem.indexOf(u8, failure, "not found") != null) return E.NOENT; // A pattern refused (Edit's too) is malformed input, as 9ns reads it; // so is a word this build has not (`invalid: acme's Put ...`). @@ -422,23 +422,39 @@ const Builtin = builtins.registry.Builtin(); /// Refuses a control message, quoting it the way Plan 9's cmderror does /// (kernel/misc/parse.c:82): `unknown control message "Bogus 3"`. fn refuse(p: *Pardes, req: Req, why: []const u8, line: []const u8) Reply { - const room = p.fs.ename.len -| (why.len + 3); - var shown: [320]u8 = undefined; - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\"", .{ why, blanked(line[0..@min(line.len, room)], &shown) }) catch why; - return tree.failText(req.tag, E.INVAL, text); + const word = firstWord(line); + // A word too long to quote whole gives up its middle (fitErr), so the + // refusal keeps both its ends. + var shown: [4096]u8 = undefined; + var whole: [4096 + 320]u8 = undefined; + const full = std.fmt.bufPrint(&whole, "{s} \"{s}\"", .{ why, blanked(word[0..@min(word.len, shown.len)], &shown) }) catch why; + // Into the session's own buffer: `whole` is this frame's. + if (full.len <= p.fs.ename.len) { + @memcpy(p.fs.ename[0..full.len], full); + return tree.failText(req.tag, E.INVAL, p.fs.ename[0..full.len]); + } + return tree.failText(req.tag, E.INVAL, tree.fitErr(full, &p.fs.ename)); +} + +/// The word a refusal quotes: a control message's first, its offending +/// word, never the line with its arguments (one rule everywhere: `wrong +/// #args in control message "Newcol"`). +pub fn firstWord(line: []const u8) []const u8 { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + return trimmed[0 .. std.mem.indexOfAny(u8, trimmed, " \t") orelse trimmed.len]; } /// `refuse`, saying which ctl takes the message instead. pub fn refuseTo(p: *Pardes, req: Req, why: []const u8, line: []const u8, ctl: []const u8) Reply { const room = p.fs.ename.len -| (why.len + ctl.len + 18); var shown: [320]u8 = undefined; - const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(line[0..@min(line.len, room)], &shown), ctl }) catch why; + const text = std.fmt.bufPrint(&p.fs.ename, "{s} \"{s}\": write it to {s}", .{ why, blanked(firstWord(line)[0..@min(firstWord(line).len, room)], &shown), ctl }) catch why; return tree.failText(req.tag, E.INVAL, text); } /// A quoted line as a refusal shows it: a control byte (a tab, the one a /// line may hold) is a blank, so the reason reads as one line of words. -fn blanked(line: []const u8, buf: *[320]u8) []const u8 { +fn blanked(line: []const u8, buf: []u8) []const u8 { const n = @min(line.len, buf.len); for (line[0..n], buf[0..n]) |c, *o| o.* = if (c < ' ' or c == 0x7f) ' ' else c; return buf[0..n]; @@ -1087,7 +1103,8 @@ test "a control character in a write to any ctl refuses the whole write, and a q } const quoted = wr(p, Node.of(serialOf(p), .ctl), "bogus\tword\n"); try testing.expectEqual(E.INVAL, quoted.errno()); - try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "bogus word") != null); + // The offending word alone is quoted: a tab ends it. + try testing.expect(std.mem.indexOf(u8, quoted.reply.ename, "\"bogus\"") != null); try testing.expect(std.mem.indexOfScalar(u8, quoted.reply.ename, '\t') == null); } @@ -1209,11 +1226,11 @@ test "the pane ctl takes acme's verbs and the pane's builtins, and refuses the r const refused = wr(p, ctl_node, bad); try testing.expectEqual(E.INVAL, refused.errno()); var want: [64]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{bad}), refused.reply.ename); + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "unknown control message \"{s}\"", .{firstWord(bad)}), refused.reply.ename); } try testing.expectEqualStrings("not a window control message \"Newcol\": write it to /ctl", wr(p, ctl_node, "Newcol").reply.ename); - try testing.expectEqualStrings("not a window control message \"Verbose off\": write it to /ctl", wr(p, ctl_node, "Verbose off").reply.ename); - try testing.expectEqualStrings("wrong #args in control message \"Collapse now\"", wr(p, ctl_node, "Collapse now").reply.ename); + try testing.expectEqualStrings("not a window control message \"Verbose\": write it to /ctl", wr(p, ctl_node, "Verbose off").reply.ename); + try testing.expectEqualStrings("wrong #args in control message \"Collapse\"", wr(p, ctl_node, "Collapse now").reply.ename); // Checked whole first: a bad line anywhere and nothing ran. try testing.expectEqual(E.INVAL, wr(p, ctl_node, "Msg first\nbogus").errno()); try testing.expect(p.panes[0].?.msg_len == 0 or !std.mem.eql(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "first")); @@ -1265,18 +1282,18 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi try testing.expectEqual(columns + 1, p.ncol); try testing.expectEqualStrings("not a session control message \"Del\": write it to pane/<n>/ctl", wr(p, root_ctl, "Del").reply.ename); - try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename); - try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename); - try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename); + try testing.expectEqualStrings("unknown control message \"Nonsense\"", wr(p, root_ctl, "Nonsense 1").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose\"", wr(p, root_ctl, "Verbose maybe").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement\"", wr(p, root_ctl, "Placement east").reply.ename); // A number's refusal names its range, a path's what it takes. - try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"InactiveDim 200\"", wr(p, root_ctl, "InactiveDim 200").reply.ename); - try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"MessageLinger x\"", wr(p, root_ctl, "MessageLinger x").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"InactiveDim\"", wr(p, root_ctl, "InactiveDim 200").reply.ename); + try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"MessageLinger\"", wr(p, root_ctl, "MessageLinger x").reply.ename); for (config.Runtime.settings) |setting| if (setting.action != .theme) try testing.expect(config.Runtime.takes(setting.action) != null); const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename; try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n"); - try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme no-such-theme\"")); - try testing.expectEqualStrings("wrong #args in control message \"Newcol 2\"", wr(p, root_ctl, "Newcol 2").reply.ename); + try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme\"")); + try testing.expectEqualStrings("wrong #args in control message \"Newcol\"", wr(p, root_ctl, "Newcol 2").reply.ename); try testing.expectEqualStrings("wrong #args in control message \"Theme\"", wr(p, root_ctl, "Theme").reply.ename); // A bare required word fails the check, so the line before never runs. try testing.expectEqual(E.INVAL, wr(p, root_ctl, "Verbose on\nMount").errno()); @@ -1760,6 +1777,71 @@ test "exec Save writes the named file through the host" { try testing.expectEqualStrings(pane.file.?.content, p.fallback.get("/tmp/renamed.txt").?); } +test "a refusal's +Unsaved goes in the session's directory when the active pane's directory is not there" { + if (comptime !pardes.hosted) return; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/pardes-no-such-dir-zz/x.txt\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "edited\n").reply.status); + try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status); + const unsaved = for (p.panes) |slot| { + const pane = slot orelse continue; + const f = pane.file orelse continue; + if (f.output) |o| if (o.from == .unsaved) break f.path; + } else return error.NoUnsavedPane; + try testing.expectEqualStrings(p.sessionDir(), std.fs.path.dirname(unsaved).?); +} + +test "a look at a file it may not read fails the write naming the file" { + if (comptime !pardes.hosted) return; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "locked.txt", .data = "secret\n", .flags = .{ .permissions = .fromMode(0o000) } }); + 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 path = try std.fmt.bufPrint(&line, "{s}/locked.txt", .{dir}); + var want: [4200]u8 = undefined; + const expected = try std.fmt.bufPrint(&want, "look: {s}: permission denied", .{path}); + var with_newline: [4300]u8 = undefined; + const refused = wr(p, root_look, try std.fmt.bufPrint(&with_newline, "{s}\n", .{path})); + if (refused.reply.status == .ok) return error.SkipZigTest; // root reads anything + try testing.expectEqualStrings(expected, refused.reply.ename); +} + +test "Pager is a setting of the root ctl: pardes by default, off taken, anything else refused with its choices" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const root_ctl = @intFromEnum(tree.TopFile.ctl); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager pardes\n") != null); + try testing.expectEqual(Status.ok, wr(p, root_ctl, "Pager off\n").reply.status); + try testing.expectEqual(config.Runtime.Pager.off, p.settings.pager); + try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, "Pager off\n") != null); + try testing.expectEqualStrings("bad value in control message; takes pardes, off \"Pager\"", wr(p, root_ctl, "Pager less\n").reply.ename); +} + +test "a refused Del written back through event fails that write, EIO, with its err" { + const gpa = testing.allocator; + const p = try withFile(gpa, "text\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .body), "more\n").reply.status); // unsaved + const event = Node.of(serial, .event); + const h = call(p, .{ .tag = 1, .op = .open, .node = event }).reply.handle; + defer _ = call(p, .{ .tag = 9, .op = .release, .node = event, .handle = h }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status); + const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes; + var copy: [256]u8 = undefined; + @memcpy(copy[0..record.len], record); + const back = wr(p, event, copy[0..record.len]); + try testing.expectEqual(E.IO, back.errno()); + try testing.expect(std.mem.indexOf(u8, back.reply.ename, "Modified") != null); + try testing.expect(p.paneBySerial(serial) != null); +} + test "a pane's own look and exec go to the event reader holding it, which writes them back to act" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg routed\n"); @@ -1951,7 +2033,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" { try testing.expectEqual(E.INVAL, themed.errno()); try testing.expectStringStartsWith(themed.reply.ename, "bad value in control message; like it: "); try testing.expect(std.mem.indexOf(u8, themed.reply.ename, ": dr") != null); - try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme drak\"")); + try testing.expect(std.mem.endsWith(u8, themed.reply.ename, "\"Theme\"")); // A click on the word says it on the message row. _ = wr(p, Node.of(serialOf(p), .exec), "Theme drak\n"); const pane = p.panes[p.active].?; @@ -2089,8 +2171,8 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back defer p.deinit(); const root_ctl = @intFromEnum(tree.TopFile.ctl); if (!builtins.capabilities.lift) { - try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift on\"", wr(p, root_ctl, "Lift on\n").reply.ename); - try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth 120\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); + try testing.expectEqualStrings("Lift is GUI-only, invalid here \"Lift\"", wr(p, root_ctl, "Lift on\n").reply.ename); + try testing.expectEqualStrings("GripWidth is GUI-only, invalid here \"GripWidth\"", wr(p, root_ctl, "GripWidth 120\n").reply.ename); // A builtin the GUI alone has, written to a ctl or an exec, never // runs as a shell command. if (comptime !pardes.font_picker) { @@ -2100,7 +2182,7 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back try testing.expectEqualStrings("Fonts is GUI-only, invalid here", ran.reply.ename); } } - try testing.expectEqualStrings("unknown control message \"Liftt on\"", wr(p, root_ctl, "Liftt on\n").reply.ename); + try testing.expectEqualStrings("unknown control message \"Liftt\"", wr(p, root_ctl, "Liftt on\n").reply.ename); var buf: [900]u8 = undefined; if (pardes.dump.directory(&buf, "")) |dir| { var want: [920]u8 = undefined; @@ -2945,13 +3027,14 @@ test "every EINVAL a write gets says why, in its err record too; DEL is a contro try testing.expect(th.logHas(p, "an empty name")); } -test "/index shows a name as the log does: a newline in it is \\n, controls spaces, bytes not UTF-8 \\xNN" { +test "/index shows a name as the log does: a newline in it is \\n, a control byte, C1 or byte not UTF-8 \\xNN" { const p = try th.withTerm(testing.allocator); defer p.deinit(); const term = p.paneBySerial(serialOf(p)).?; - p.setCwd(term, "/tmp/two\nlines\x7f\xff"); + p.setCwd(term, "/tmp/two\nlines\x07\x7f\xc2\x85\xff"); const index = rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes; - try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines \\xff ") != null); + // Each byte as itself, so the name decodes to what it is. + try testing.expect(std.mem.indexOf(u8, index, "/tmp/two\\nlines\\x07\\x7f\\xc2\\x85\\xff ") != null); try testing.expectEqual(@as(usize, 1), std.mem.count(u8, index, "\n")); try testing.expect(index.len > 0); try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(tree.TopFile.index) }).reply.attr.size); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index bacf3ad6..c0b52d03 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -6,6 +6,7 @@ const exec = @import("../exec.zig"); const look = @import("../look.zig"); const cloud9 = @import("cloud9"); const tree = @import("tree.zig"); +const ctl = @import("ctl.zig"); const pane_files = @import("pane.zig"); const dump = @import("../dump.zig"); @@ -336,9 +337,10 @@ fn sanitize(record: []const u8, out: []u8) []u8 { /// `text` as one line of UTF-8 a reader can split and decode, as /log and /// /index show names: a backslash is `\\`, a newline `\n` (a name with one -/// stays readable as what it is, not run into the next), any other control -/// character, DEL or C1 control (U+0080-U+009F) a space, and a byte that -/// is not UTF-8 `\xNN`. `out` of 4 bytes a byte of `text` holds it all. +/// stays readable as what it is, not run into the next), and any other +/// control character, DEL, C1 control (U+0080-U+009F) or byte that is not +/// UTF-8 `\xNN`, so a name read there decodes to the bytes it is. `out` of +/// 4 bytes a byte of `text` holds it all. pub fn shown(text: []const u8, out: []u8) []u8 { return shownAs(text, out, true); } @@ -364,8 +366,15 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { continue; } if (c < ' ' or c == 0x7f) { - out[w] = ' '; - w += 1; + // A name's (escape_newline) is `\xNN`, so it reads back as the + // byte it was; a message's a space. + if (escape_newline) { + _ = std.fmt.bufPrint(out[w..], "\\x{x:0>2}", .{c}) catch break; + w += 4; + } else { + out[w] = ' '; + w += 1; + } i += 1; continue; } @@ -377,6 +386,13 @@ fn shownAs(text: []const u8, out: []u8, escape_newline: bool) []u8 { continue; } if (n == 2 and c == 0xC2 and body[i + 1] <= 0x9F) { + if (escape_newline) { + if (w + 8 > out.len) break; + _ = std.fmt.bufPrint(out[w..], "\\xc2\\x{x:0>2}", .{body[i + 1]}) catch break; + w += 8; + i += 2; + continue; + } out[w] = ' '; w += 1; i += 2; @@ -494,18 +510,6 @@ pub fn writeLog(p: *Pardes, req: Req) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -/// The length a stat reports: the text an open would freeze now. -pub fn logLen(q: *const Queue) u64 { - var n: u64 = 0; - var at = q.head; - while (at + 4 <= q.buf.items.len) { - const len = std.mem.readInt(u32, q.buf.items[at..][0..4], .little); - n += len; - at += 4 + len; - } - return n; -} - // ---- per-pane event records ---- pub const max_record_text = 256; @@ -697,11 +701,27 @@ fn scanNumber(data: []const u8, i: *usize) ?u32 { /// Writing a Look or Exec record back performs the action it names. /// The word a click at `at` expands to: the file name or word run under it /// on its line (look.expandedWord), else nothing. -fn expandAt(whole: []const u8, at: usize) []const u8 { +/// An empty range written back: what a click at `at` takes, as the mouse's +/// does (exec.pointerOperand). In a diff pane's body that is look.diffClick's +/// answer, the line's address or its code's words. +pub fn expandAt(p: *Pardes, pane: *Pane, whole: []const u8, at: usize, in_body: bool) []const u8 { const start = if (std.mem.lastIndexOfScalar(u8, whole[0..at], '\n')) |nl| nl + 1 else 0; const end = std.mem.indexOfScalarPos(u8, whole, at, '\n') orelse whole.len; - const b = look.expandedWord(whole[start..end], at - start) orelse return ""; - return whole[start + b.lo .. start + b.hi]; + var word_from: usize = 0; + if (in_body) diff: { + const row = std.mem.count(u8, whole[0..start], "\n"); + // Only when the served line is the one the diff reads there. + const lines = look.diffLines(p, pane) orelse break :diff; + if (row >= lines.len or !std.mem.eql(u8, std.mem.trimEnd(u8, lines[row], "\r"), std.mem.trimEnd(u8, whole[start..end], "\r"))) break :diff; + var buf: [4096 + 32]u8 = undefined; + const click = look.diffClick(p, pane, row, at - start, &buf) orelse break :diff; + if (click.address) |address| return p.scratch.allocator().dupe(u8, address) catch ""; + word_from = click.word_from; + } + const line = whole[start..end]; + if (at - start < word_from or word_from > line.len) return ""; + const b = look.expandedWord(line[word_from..], at - start - word_from) orelse return ""; + return whole[start + word_from + b.lo .. start + word_from + b.hi]; } pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { @@ -732,6 +752,11 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { } if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event ++ ": a record is <origin><action><q0> <q1> <flag> <count> <text>, a line each"); } + // What a record written back runs fails the write as a click written + // to exec would (a refused Del: EIO and its err), not said and lost. + p.fs.capturing = true; + p.fs.failure_len = 0; + defer p.fs.capturing = false; var run: EventReader = .{ .data = req.data }; while (run.next()) |r| { const live = p.paneBySerial(serial) orelse break; @@ -742,7 +767,7 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { // An empty range with no text is a click at that point: expanded as // the click would be, to the word or file name under it (acme's // expand, look.c). - const said = if (r.q0 == r.q1) r.text orelse expandAt(whole, lo) else whole[lo..hi]; + const said = if (r.q0 == r.q1) r.text orelse expandAt(p, pane, whole, lo, !r.action.onTag()) else whole[lo..hi]; const text = p.scratch.allocator().dupe(u8, said) catch continue; switch (r.action) { // A body's click written back is done as the click would have @@ -811,6 +836,10 @@ pub fn writeEvent(p: *Pardes, asked: Req, id: usize) Reply { else => unreachable, } } + if (p.fs.failure_len > 0) { + const failure = p.fs.failure[0..p.fs.failure_len]; + return tree.failText(req.tag, ctl.failureErrno(failure), std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure); + } return .{ .tag = req.tag, .written = @intCast(asked.data.len) }; } @@ -981,10 +1010,10 @@ test "a pane deleted while its event file is open leaves no suppression behind" _ = wr(p, Node.of(serial, .ctl), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); - // The reader's next read hears what acme says of a window gone under it. + // The reader's next read is its end: no more records, no error. const shut = call(p, .{ .tag = 19, .op = .read, .node = event, .handle = a.reply.handle, .size = 64 }); - try testing.expectEqual(E.NOENT, shut.errno()); - try testing.expectEqualStrings(tree.e_shut_down, shut.reply.ename); + try testing.expectEqual(tree.Status.ok, shut.reply.status); + try testing.expectEqualStrings("", shut.bytes); _ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle }); _ = call(p, .{ .tag = 21, .op = .release, .node = event, .handle = b.reply.handle }); @@ -1388,7 +1417,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are test "a long err record keeps its end, its middle given up to an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); - _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n"); + _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus" ++ "abcdefgh" ** 40 ++ "\n"); const log = @intFromEnum(tree.TopFile.log); const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index be03e8f0..c71a706f 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -646,7 +646,9 @@ fn topSize(p: *Pardes, f: TopFile) u64 { .README => fs.help.len, .status => ctl.statusLen(p), .look, .exec, .tagexec => ctl.resultsLen(p), - .log => events.logLen(&p.fs.log), + // A stream: a length would read as its end to a kernel that caches + // one (a 9ns mount's splice), and a follower's reads go past it. + .log => 0, .ctl => ctl.rootLen(p), .commands => ctl.commandsLen(), .focus => focus: { @@ -1092,10 +1094,14 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply { // A run's answer outlives the pane it ran in. if (t.file == .pty_run and req.handle != 0) return pty.readRun(p, req); const id = p.paneBySerial(t.serial) orelse { - // An event or pty/data read the pane closed under: acme's - // answer to the same (editors/acme/xfid.c:1005). - if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data) - return failText(req.tag, E.NOENT, e_shut_down); + // An event reader whose pane closed reads its end: there + // are no more records, as a pipe's writer gone is EOF, so + // `while read` and `cat` end cleanly. A pty/data read the + // pane closed under keeps acme's answer (xfid.c:1005). + if (openOf(p, req)) |o| { + if (o.what == .event) return .{ .tag = req.tag }; + if (o.what == .pty_data) return failText(req.tag, E.NOENT, e_shut_down); + } return Reply.fail(req.tag, E.NOENT); }; const pn = p.panes[id].?; @@ -1335,6 +1341,14 @@ test "filesystem inspection preserves pending and displayed Look hover" { try testing.expect(!p.raw_hover_intent); } +test "log stats 0 however much it holds, a stream as acme's generated files are" { + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + _ = th.wr(p, Node.of(th.serialOf(p), .exec), "Msg a record for the log\n"); + try testing.expect(th.logHas(p, "a record for the log")); + try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.log) }).reply.attr.size); +} + test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ // Opening `new` is what makes a pane now; walking to it makes none, diff --git a/src/panes.zig b/src/panes.zig index 0380f89f..a395d6ed 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -124,6 +124,10 @@ pub const Pane = struct { /// A command pane (exec.zig runCommand): the line its child runs as /// `$SHELL -c`, owned, where an interactive terminal has null. command: ?[]u8 = null, + /// The pane whose tag or text the command was run from, its child's + /// `$winid` (acme's name); 0 for one from a column's or the workspace's + /// tag, which no pane owns. + command_from: u32 = 0, /// The command's child has exited, and with what status (null: unknown, /// shown `exit ?`). command_done: bool = false, diff --git a/src/pardes.zig b/src/pardes.zig index 47ebf8fa..aa2c252c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4180,6 +4180,8 @@ pub const Boot = enum { document, /// a Look that found nothing: reported, not opened missing, + /// `pardes -`: stdin's text in a +Pager pane, as `vim -` shows it + stdin, /// the firmware's boot buffer, the only pane a board has embedded, /// the bare tty startup: one shell in raw tty mode, with an empty text @@ -4196,6 +4198,7 @@ pub const Boot = enum { if (opts.boot) |named| return named; if (opts.file != null) return .document; if (opts.missing != null) return .missing; + if (opts.stdin_text != null) return .stdin; if (comptime platform == .esp32p4) return .embedded; if (opts.tty_only) return .tty_shell; // ponytail: 1 and 3 are the only shell counts anyone asked for @@ -4215,6 +4218,8 @@ pub const Options = struct { file: ?[]const u8 = null, file_line: usize = 0, missing: ?struct { word: []const u8, dir: []const u8 } = null, + /// `pardes -`: stdin, its escapes stripped, for a +Pager pane at boot. + stdin_text: ?[]const u8 = null, tty_toggle: u21 = config.tty_toggle_default, /// load a dump of another instance instead of spawning shells (acme -l) load_path: ?[]const u8 = null, @@ -4626,6 +4631,17 @@ pub const Pardes = struct { _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); p.oneColumn(); }, + .stdin => { + const content = try gpa.dupe(u8, opts.stdin_text.?); + errdefer gpa.free(content); + const dir = p.sessionDir(); + const shown = try panes.Output.open(p, 0, dir, .{ .cmd = .New }, "", content); + // Named +Pager, as `pardes -` inside a session names its pane. + const named = try std.fs.path.join(gpa, &.{ dir, "+Pager" }); + gpa.free(shown.file.?.path); + shown.file.?.path = named; + p.oneColumn(); + }, .embedded => { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); diff --git a/src/tagline.zig b/src/tagline.zig index dc7d10d4..9bd49eef 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -389,7 +389,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.normal.clear(); } -/// A dump's tag tail, what was typed after the name: a dump from 0.0.4 on +/// A dump's tag tail, what was typed after the name: a dump from 0.4 on /// writes it, and an older one's saved tag is not read. pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { const tail = src.tag_tail orelse return; |
