From 9070942b29bd10dddcdecdb0e88ba0fb40608467 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 20:07:43 -0300 Subject: Plan 9 idiom for the control filesystem, and the regressions a624a56 left The 9P tree stops being a command language wearing a filesystem. /new created a pane as a side effect of a *read*; it is now Tcreate in /pane, with Tremove to close, which cloud9's engine has always supported and the editor never declared: tree.zig now says `features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs become files that can be read as well as written -- dot, limit, dirty, mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file would say better. Root /ctl splits into a read-only /status and the /look and /exec files whose write IS the click. stat carries real sizes where it used to answer 0, and qid versions track a pane's revision, so a client can poll for change without re-reading the body. Commit a624a56 moved raw-tty keys to an early-return branch that knew only Ctrl-B and bare Escape, and in the same edit deleted the paste branch below it. That cost Shift-Escape (the unconditional way out of tty mode) and both paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an agent CLI running in a pane took Ctrl-V for its image-paste binding and answered "No image found in clipboard". Both are restored, with tests. Nested detection was not subtly broken but deleted: 60367d8 removed nested.zig's process-ancestry walk and left "am I inside pardes" derived from PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener did not come up". PARDES_PID now answers that question on its own, checked with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag is gone. The posted-9P registry also self-heals now -- a session that aborts cannot unlink its own socket, so posting sweeps entries whose target refuses a connection, symlinks only and on a definite ECONNREFUSED only. Elsewhere: tty scrolling is sticky-bottom, following new output only from the last row, with typing and entering raw mode snapping back to live; the boot layouts are a Boot enum instead of a chain of ifs, and the bare tty startup (Boot.tty, which main.zig names) opens an empty text pane under the shell while tests keep Boot.tty_shell; builtins announce themselves on the message row under a Verbose setting that is on by default; Config prints each setting the way you would type it back, so WindowOpacity 70 rather than "WindowOpacity: 70%"; LocationsConfig opens its window only when called bare; every tagline puts the word that closes the thing last, and a column now outlives its panes -- closing the last one leaves an empty pane, and only Delcol, newly on the column tagline, takes the column away. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/pardes-9p/SKILL.md | 271 +++++++++++++++++++------------- docs/cloud9.md | 11 ++ docs/config.md | 18 ++- docs/design.typ | 31 ++-- docs/fs.md | 120 +++++++++----- docs/v9fs.md | 2 +- features.txt | 48 ++++++ src/9p_io.zig | 290 +++++++++++++++++++++++++++++++--- src/builtins.zig | 15 +- src/config.zig | 187 +++++++++++----------- src/fs-help.txt | 42 ++--- src/fs.zig | 11 +- src/host_io.zig | 3 + src/main.zig | 71 +++++++-- src/ninep/addr.zig | 16 +- src/ninep/ctl.zig | 319 ++++++++++++++++---------------------- src/ninep/events.zig | 15 +- src/ninep/pane.zig | 241 ++++++++++++++++++++++++---- src/ninep/pty.zig | 9 +- src/ninep/screen.zig | 25 ++- src/ninep/testing.zig | 29 +++- src/ninep/tree.zig | 207 ++++++++++++++++--------- src/panes.zig | 14 +- src/pardes.zig | 294 +++++++++++++++++++++++++++++------ src/tty/tty.zig | 31 ++++ src/tutor.txt | 21 +-- test/appearance_gallery.py | 14 +- test/column_tags.py | 4 +- test/e2e_harness.zig | 2 +- test/font_size.py | 6 +- test/fs.py | 134 +++++++++------- test/fs_bench.zig | 4 +- test/fs_namespace.zig | 15 +- test/fs_soak.py | 8 +- test/look_placement.py | 6 +- test/ninep.py | 19 +++ test/output.zig | 32 ++-- test/panes.zig | 76 ++++++++- test/pdf.zig | 2 +- test/tag_cleanup.py | 2 +- test/ui_review.py | 12 +- test/v9fs.py | 35 +++-- test/window_opacity.py | 2 +- 43 files changed, 1898 insertions(+), 816 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 09dee666..2cea4d4a 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -1,23 +1,127 @@ --- name: pardes-9p -description: Inspect and drive a running Pardes editor over 9P, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging through its control filesystem. +description: Inspect and drive a running Pardes editor through its control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging. --- # Pardes over 9P -Use the existing [Python client](../../../test/ninep.py) for ad hoc interaction -and functional tests. Do not build another wire client. Project tooling stays -in Zig. Run the examples from the repository root; paths below are relative to -that root unless linked. +Pardes is driven by reading and writing files. Prefer ordinary file tools over +a mount; reach for the Python client only when nothing is mounted, or when the +work needs a fid held open. Do not build another wire client: project tooling +stays in Zig. Run the examples from the repository root; paths below are +relative to that root unless linked. -## Connect and identify panes +## Find the session through the mount -Every native session opens a Unix socket. Inside a pane, `PARDES_9P` names the -socket and `PARDES_PANE` is that pane's serial. Outside Pardes, find -`pardes-9p-*.sock` under `$XDG_RUNTIME_DIR`, or `~/.local/state/pardes` when -that variable is unset. Select the intended session explicitly; do not assume -the newest socket is the right one. Names come from `--9p=name`, the detached -session name, or the process ID. +This machine keeps a FUSE mount of every posted session under `/mnt/9p`: +`/mnt/9p/pardes//` is one session's served root. Inside a pane, +`$PARDES_9P` is that session's socket (`/run/user/1000/pardes-9p-.sock`), +so its pid is the directory to use, and `$PARDES_PANE` is the calling pane's +serial: + +```sh +m=/mnt/9p/pardes/$(basename "$PARDES_9P" .sock | sed 's/^pardes-9p-//') +cat "$m/index" +cat "$m/pane/$PARDES_PANE/body" +``` + +Entries for dead sessions stay listed and answer `Input/output error` on any +access, so name the session you mean rather than globbing or taking the newest. +`cat "$m/index"` is the cheapest liveness check, and `cat "$m/status"` reports +the `pid`, `version` and `panes` of a session new enough to serve it. + +`/mnt/9p/pardes` is the registry of posted sessions, mounted once for the +machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which +gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md) +for that. Either mountpoint serves the same tree. + +A running session serves whatever binary started it. If a listing does not +match this document, that session predates the change; restart it. + +## The tree, and what to do with it + +``` +$m/README the served guide, worth reading first +$m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name +$m/status pid, version, panes +$m/look write a line = a right click on it at the active pane +$m/exec write a line = a middle click: an editor command word, or a shell line +$m/log one record per read: new|del|rename|save ; reads park +$m/screen the rendered screen as JSON, frozen per open +$m/listeners this session's dial addresses +$m/pane/ mkdir opens a pane; rmdir closes it +$m/os/ the host filesystem +``` + +The first field of an index row is a stable pane serial, not a slot or row +number. A row is `serial kind dirty name`, so `row.split(maxsplit=3)` keeps a +name with spaces intact. Re-read the index after anything that might open, +reuse or close a pane. + +```sh +cat "$m/index" # which panes exist +mkdir "$m/pane/x"; n=$(awk 'END{print $1}' "$m/index") # open one, take its serial +printf 'text\n' > "$m/pane/$n/body" # append +cat "$m/pane/$n/tag" # what its tagline offers +echo notes.txt > "$m/pane/$n/name" # rename the buffer +echo Save > "$m/pane/$n/exec" # save it +echo "/etc/hosts:3" > "$m/look"; cat "$m/look" # open a file, see where it landed +rmdir "$m/pane/$n" # close it, dirty or not +``` + +The name `mkdir` asks for is ignored: a pane is named by the serial the editor +gives it, and because `/index` is ordered by serial its last row is the pane +just made. Nothing else in the tree can be created or removed, and no read +creates anything, so `ls`, `stat` and `find` over the whole tree are inert. + +`look` and `exec` are the editor's two clicks, one per line of a write, at the +active pane from the root and at that pane from `$m/pane//look` and +`$m/pane//exec`. Reading any of them answers the serials the last command +made, or the pane it focused or acted on. A command that fails is reported in +the editor, not as a write error, so inspect the resulting pane, index, message +or screen; only a malformed line fails the write itself. + +## Edit through addresses, dot and the flag files + +For a file or scratch pane, with `pane=$m/pane/`: + +| Operation | Shell | Python client | +|---|---|---| +| Read text | `cat $pane/body` | `client.read(pane + '/body')` | +| Append text | `echo text >> $pane/body` | `client.write(pane + '/body', b'text\n')` | +| Replace all text | `echo text > $pane/body` | `client.write(pane + '/body', b'text\n', truncate=True)` | +| Address a byte range | `echo '#0,#2' > $pane/addr` | `client.write(pane + '/addr', b'#0,#2')` | +| Replace that range | `echo 'pub fn' > $pane/data` | `client.write(pane + '/data', b'pub fn')` | +| Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads | +| Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` | +| Reload from disk | `echo get > $pane/ctl` | `client.write(pane + '/ctl', b'get\n')` | +| Close it | `rmdir $pane` | `client.remove(pane)` | + +`addr`, `dot` and `limit` each read the pair of offsets they also accept, which +is why copying one onto another is all that acme's `addr=dot`, `dot=addr` and +`limit=addr` ever were; a write may also be an address expression (`#0,#5`, +`/pattern/`, `2+1`). Moving `dot` scrolls the pane to it. `limit` bounds a +search and reads empty until set; truncate it to lift it. + +`dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the +buffer differs from its file, whether a write pushes an undo point, and whether +a write scrolls. Truncating `tag` clears the part of the tag you may edit. + +Address state belongs to the pane, not to a client: opening `addr` resets it, +so two clients addressing the same pane will interfere. `$pane/ctl` reads +acme's window status line — serial, tag length, body length, a reserved zero, +the dirty flag, the width in cells, the font and the tab width — and takes the +one verb `get`. + +Terminal panes have no file: writing their `body` sends child input, and +truncation does not erase terminal history. + +## The Python client, for what a shell cannot express + +Use the existing [Python client](../../../test/ninep.py) when there is no +mount, or when the work needs a fid held open across several operations — +`log`, `event` and `pty/data` are consuming queues whose reads park, and shell +redirection cannot hold one open. ```sh PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY' @@ -25,103 +129,56 @@ import sys from ninep import Client with Client(sys.argv[1]) as client: - print(client.read('/self/index').decode(), end='') - print(client.read('/self/listeners').decode(), end='') + print(client.read('/index').decode(), end='') + print(client.read('/listeners').decode(), end='') PY ``` -`Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it -does not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, -uses a five-second socket timeout, and closes on leaving `with`. - -The first field of each index row is a stable pane serial, not a slot or row -number. The tag starts after five numeric fields; use `row.split(maxsplit=5)` -to preserve spaces. Inspect `/self/pane//tag`, `body`, or directory -entries before choosing a target. Re-read the index after actions that might -open, reuse or close panes. - -Wire paths start with `/self` or `/os`. `/n/self`, `/n/os`, `/n/peer` and -`/virtual` belong to editor Look paths, not the server root. For example, -Look `/virtual/src/pardes.zig` corresponds to reading `/self/src/pardes.zig`. - -## Edit, Look and execute - -For a file or scratch pane, with a connected `client` and a confirmed -`serial`, let `pane = f'/self/pane/{serial}'`. Terminal body writes instead -send child input; truncation does not erase terminal history. - -| Operation | Client call | -|---|---| -| Read text | `client.read(pane + '/body')` | -| Append text | `client.write(pane + '/body', b'text\n')` | -| Replace all text | `client.write(pane + '/body', b'text\n', truncate=True)` | -| Select a byte range | `client.write(pane + '/addr', b'#0,#2')` | -| Replace that range | `client.write(pane + '/data', b'pub fn')` | -| Show the addressed selection | `client.write(pane + '/ctl', b'dot=addr\n')` | -| Look from this pane | `client.write(pane + '/ctl', b'look /virtual/src/pardes.zig:10\n')` | -| Save | `client.write(pane + '/ctl', b'put\n')` | -| Reload | `client.write(pane + '/ctl', b'get\n')` | -| Close, refusing dirty text | `client.write(pane + '/ctl', b'del\n')` | - -`delete` force-closes, discarding unsaved text. `get` replaces edits with file -contents. Use those only when that loss is intended. Reading `/self/new/ctl` -creates a scratch pane and returns its serial as the first field; it is not -an observational read. - -`ctl` is not a builtin interpreter. Execute builtins through a body Exec event -on an owned scratch pane: - -```python -from fs import new_pane, execute - -control = new_pane(client, b'') -execute(client, control, 'Msg 9p-ready') -``` - -`execute` overwrites that pane's body with the command, then writes -`MX0 \n` to its `event` file. Keep the control pane separate -from user text. The same helper can run `Mini path`, `Mount peer dial`, and -`Unmount peer`; commands resolve relative to the control pane's directory. -A successful event write acknowledges dispatch, not completion: inspect the -resulting pane, message or screen for success. +`Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it does +not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, uses a +five-second socket timeout, and closes on leaving `with`. Its paths are the +served root: `/index`, `/pane/2/body`, `/os/...`. `/n/self`, `/n/os`, `/n/peer` +and `/virtual` are editor Look paths, not server paths — Look +`/virtual/src/pardes.zig` corresponds to reading `/src/pardes.zig`. -Opening `addr` resets its range. To inspect the current selection, open -`addr` first, write `addr=dot\n` to `ctl`, then use `read_fid` on that already -open handle. Do not replace that last step with `client.read`, which reopens -and resets it. Address state is shared by the pane, not private to a client. +For live terminal output or plugin events, use `open` / `read_fid` / `close`, +not the read-until-EOF helper. `pty/data` captures output while held open; it +is not a history replay. Both files are shared, consuming queues, not +per-client broadcasts, so a slow reader loses older data. Holding `event` open +intercepts that pane's Look and Exec clicks, so it is not a passive logger: for +Look/Exec records whose offsets identify the intended text, forward the short +record ` \n`, not the whole report with flags and text. +Expansion and chord reports need explicit handling, and terminal-body events +can carry text only in the report, which a short writeback cannot reproduce. +Read the event implementation before building an interceptor. Close handles in +`finally`, and disconnect after a socket timeout. The service shares four +connection slots and 32 screen/terminal-history snapshot handles. + +A file's qid version is the pane's revision for `body`, `data` and `xdata`, so +`stat` sees an edit land without reading the text; it stays zero elsewhere. +Stat sizes are real, and for `log`, `event` and `pty/data` report the length of +the record a read would answer — zero when nothing is waiting. ## Terminal input and screen observations Only terminal panes have `pty/`. Write keystroke bytes to `pty/data`, not `body`: `client.write(pane + '/pty/data', b'printf hello\r')` submits a shell -command. For an interactive application, send its actual input bytes; -`b'\x03'` is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. -These inputs go to the child terminal, not Pardes editor key bindings. +command. For an interactive application, send its actual input bytes; `b'\x03'` +is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. These go +to the child terminal, not to Pardes key bindings. `pty/ctl` takes `winsize C R`, +`sig INT` and `exec`, one per line. -`client.screen()` returns `cols`, `rows`, `cursor`, `styles`, and row-major +`client.screen()` returns `cols`, `rows`, `cursor`, `styles` and row-major `cells` of `[grapheme, style_index]`. Reconstruct rows using `cols`; resolve -each cell's style through `styles` when checking highlighting. Compare colors -and attributes, not just style-table indices or flattened text. +each cell's style through `styles` when checking highlighting, and compare +colors and attributes rather than style-table indices or flattened text. -Each screen open freezes one frame. A terminal `body` freezes history on its -first read. `client.read` and `client.screen` reopen each time; use repeated -calls for fresh observations. Do not poll a stale handle. Poll a specific -condition with a deadline and a short delay, rather than a fixed long sleep. -For large histories, measure whole-body reads separately from screen polling; -9P write-to-observation timing includes RPC, rendering and polling overhead. - -For live terminal output or plugin events, use `open` / `read_fid` / `close`, -not the read-until-EOF helper. `pty/data` captures output while held open; -it is not a history replay. Both files are shared, consuming queues, not -per-client broadcasts; slow readers can lose older data. Holding `event` open -intercepts Look/Exec clicks, so it is not a passive logger. For Look/Exec -records whose offsets identify the intended file/tag text, forward the short -record ` \n`, not the entire report with flags and text. -Expansion/chord reports need explicit handling; terminal-body events can have -empty ranges with text carried only in the report, so short writeback cannot -reproduce them. Read the event implementation before building an interceptor. -Close handles in `finally`; disconnect after a socket timeout. The service -shares four connection slots and 32 screen/terminal-history snapshot handles. +Each screen open freezes one frame, and a terminal `body` freezes its history +on its first read. `client.read` and `client.screen` reopen each time, so +repeat the call for a fresh observation rather than polling a stale handle. +Poll a specific condition with a deadline and a short delay, not a fixed long +sleep. For large histories, measure whole-body reads separately from screen +polling; write-to-observation timing includes RPC, rendering and polling. ## Exercise an isolated session @@ -140,22 +197,23 @@ binary = str(Path(sys.argv[1]).resolve()) with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory: with session(binary, Path(directory), 'skill') as (client, address): serial = new_pane(client, b'fn main() void {}\n') - pane = f'/self/pane/{serial}' - client.write(pane + '/ctl', b'name probe.zig\n') + pane = f'/pane/{serial}' + client.write(pane + '/name', b'probe.zig\n') client.write(pane + '/addr', b'#0,#2') client.write(pane + '/data', b'pub fn') assert client.read(pane + '/body') == b'pub fn main() void {}\n' - control = new_pane(client, b'') - execute(client, control, 'Msg 9p-ready') + execute(client, serial, 'Msg 9p-ready') frame = client.screen() assert '9p-ready' in ''.join(cell[0] for cell in frame['cells']) print('9P edit, builtin and screen checks passed') PY ``` -For terminal tests, use `session(..., tty=True)` and read +`new_pane` is `mkdir` plus a read of the index; `execute` writes one line to a +pane's `exec`. Both are in `test/fs.py`. For terminal tests, use +`session(..., tty=True)` and read [test/agent_session.py](../../../test/agent_session.py) for bounded interactive -driving. Its readiness text and history threshold are application-specific; +driving; its readiness text and history threshold are application-specific, and session cleanup alone does not guarantee arbitrary grandchildren have exited. Use [test/snapshot.zig](../../../test/snapshot.zig) when editor key/mouse input or an independent terminal-rendering comparison matters; 9P screen inspection @@ -163,6 +221,7 @@ alone does not test physical input routing or the host renderer. Read [docs/fs.md](../../../docs/fs.md) for runtime mounts, TCP/QUIC listeners, Plan9port and Linux v9fs compatibility. Unix is always available; network -listeners are opt-in and grant full session/OS-file access. Use isolated -loopback listeners for tests. For less common control verbs or event details, -read their implementation and tests in [src/fs.zig](../../../src/fs.zig). +listeners are opt-in and grant full session and OS-file access, so use isolated +loopback listeners for tests. For event details or anything this page leaves +open, read the implementation and its tests in +[src/ninep/](../../../src/ninep/). diff --git a/docs/cloud9.md b/docs/cloud9.md index 79fda717..d030db1d 100644 --- a/docs/cloud9.md +++ b/docs/cloud9.md @@ -54,6 +54,17 @@ way a private `ZMX_DIR` does for zmx. Stopping unposts, and only while the entry is still ours, so a name another editor has since claimed is never unlinked. +An exit that cannot run any code of its own — an aborted test, a kill, a +crash — leaves its entry and its socket behind, so posting first sweeps the +group: every entry that is a symlink and whose socket answers a connect with +a definite ECONNREFUSED is unlinked, along with the socket it points at when +a `stat` agrees that is a socket of ours. Anything that is not a symlink is +somebody else's, and any other answer — connected, busy, refused permission, +a surprise — counts as live, because uncertainty belongs to the server that +owns the socket rather than to the sweeper. That is `cloud9.post.Probe`'s +classification, repeated in `src/9p_io.zig` only because `post.probe` is raw +Linux syscalls and pardes also builds for darwin. + **Consuming.** Nothing. `9ns --mntgen` mounts the whole registry at `/mnt/9p`, and an interactive fish already self-wraps in one, so a pardes started from a terminal sees every posted service as ordinary files — diff --git a/docs/config.md b/docs/config.md index a55aef1c..04b0fd82 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,6 +111,10 @@ Workspace, column and pane command text can be edited directly; see `ColumnTags` toggles the column command row in both GUI and TTY; it is on by default. Add `ColumnTags` to startup configuration to reclaim that row on a compact screen. Hiding it preserves your custom column commands. +`Verbose` toggles the message-row announcement every builtin makes of its own +name before it runs; it is on by default, and the builtins that own the message +row themselves (`Msg`) never announce. Turning it off leaves the row to the +messages a builtin chooses to write. `SyntaxBold` toggles bold syntax keywords; it is off by default. These settings are shared by GUI and TTY, and `Config` reports their current states. Like `Colors` and `Wrap`, these commands take no argument and invert @@ -334,10 +338,16 @@ The installed `pardes-v9fs` helper lives beside the editor; development builds can set `PARDES_V9FS_HELPER` to its absolute path. See [v9fs.md](v9fs.md). Ctrl-B switches between raw TTY and editor mode. Plain Escape at a detected -shell prompt hops back to the previous pane. Other keys, including Ctrl-O, Ctrl-W, -paste shortcuts, and modified Escape belong -to the child. Use `Mode` in the pane tag to return to editor -mode in place. Desktop paste events still feed the terminal. +shell prompt hops back to the previous pane. Other keys, including Ctrl-O, +Ctrl-W and modified Escape, belong to the child. Use `Mode` in the pane tag to +return to editor mode in place. Desktop paste events still feed the terminal. + +The paste chords are the exception the window keeps. Ctrl-V types the yank +register at the program, and Ctrl-Shift-V asks the desktop for its clipboard +and types that; both go through the program's bracketed paste when it has +asked for one. Neither reaches the child as a keystroke, so an application +that would otherwise answer Ctrl-V by reading the system clipboard itself +never gets the chance to read the wrong thing. `Font` and `FontSel` exist ONLY in the SDL GUI and native macOS builds — a terminal's font belongs to its emulator and a browser's to the page — so a diff --git a/docs/design.typ b/docs/design.typ index 4c8956ba..f8f0b05c 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1492,15 +1492,20 @@ same thread as editing. == Nested Look -Pane shells inherit `PARDES_9P`, `PARDES_PANE` (the pane serial), and -`PARDES_FORWARD_LOOK`. A child launch resolves its OS-relative argument in -the child's working directory, then writes `look ` to the parent's -`self/pane//ctl`. Explicit `/virtual` and `/n` paths resolve in the -parent. The ordinary filesystem update performs layout and drains host effects. - -`--nested` starts a separate editor and disables forwarding from its direct -pane shells. Its 9P socket remains available for control and plugins. There is -no executable-name discovery or separate Look listener. +Pane shells inherit `PARDES_PID` (the editor's process id), `PARDES_9P` and +`PARDES_PANE` (the pane serial). The first answers whether the shell is inside +a pardes at all, the other two answer how to reach it, and they are separate +because a session whose listener never came up still owns its children. A child +launch that finds a live `PARDES_PID` and no way to reach it says so instead of +starting a second editor. Otherwise it resolves its OS-relative argument in the +child's working directory, then writes `look ` to the parent's +`pane//ctl`. Explicit `/virtual` and `/n` paths resolve in the parent. +The ordinary filesystem update performs layout and drains host effects. + +`--nested` starts a separate editor and withholds `PARDES_PID` from its direct +pane shells, which is the whole of the opt-out. Its 9P socket remains available +for control and plugins, so those shells still get `PARDES_9P` and +`PARDES_PANE`. There is no executable-name discovery or separate Look listener. Detached frontends use `pardes-detached-.sock` in the same runtime directory. The shared Unix socket conventions live in `src/9p_io.zig`. @@ -1928,9 +1933,11 @@ arrives bracketed, as one `paste` event. staged edits: Enter commits a new buffer save target, Escape cancels, and no disk rename or write happens until explicit Save. Terminal cwd and image/PDF status stay generated. Save leads the tail of every pane holding text of its own: -`Save Tty Del Collapse` for a file or an output buffer, -`Save Tty Del Togglettymode Filter Collapse` for a terminal, and `Tty Del Collapse` -for an image. PDFs use `Tty Del PdfSections PdfTint Collapse`, without tint status text. +`Save Tty Collapse Del` for a file or an output buffer, +`Tty Save Mode Filter Collapse Del` for a terminal, and `Tty Collapse Del` +for an image. PDFs use `Tty PdfSections PdfTint Collapse Del`, without tint status text. +The word that closes the thing is last on every tagline, so overshooting the +click before it cannot destroy anything. `Collapse` toggles a pane between its tagline alone and its expanded height; hidden body contents and running terminals are retained. An unsaved file has `*` after its name; an image tag diff --git a/docs/fs.md b/docs/fs.md index 55ffdc9c..0bcc0313 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -1,14 +1,19 @@ # Filesystem Every native session serves 9P2000 on a Unix socket. Pane shells receive -`PARDES_9P` (socket path) and `PARDES_PANE` (pane serial). The socket is +`PARDES_PID` (the editor's process id), `PARDES_9P` (socket path) and +`PARDES_PANE` (pane serial). The socket is `$XDG_RUNTIME_DIR/pardes-9p-.sock`, or lives under `~/.local/state/pardes` when XDG_RUNTIME_DIR is unset. Detached sessions use their session name; `--9p=` overrides it. A `pardes ` launched from a pane forwards Look to that pane over 9P. -`--nested` opens a separate editor and disables forwarding from its pane shells; -its 9P service stays available. +`PARDES_PID` alone says the shell is inside pardes; `PARDES_9P` and +`PARDES_PANE` say how to reach it, and a launch that has the first without the +other two refuses rather than opening a second editor. `--nested` opens a +separate editor and withholds `PARDES_PID` from its pane shells, so a pardes +started in one of them runs a session of its own; its 9P service stays +available. Look resolves the OS filesystem first, then the editor's virtual filesystem. Explicit paths bypass that search: @@ -68,53 +73,79 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC. ``` /README this guide, also src/fs-help.txt /index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name -/ctl write: one command per line; read: the serials the last command made or touched -/new reading it creates one empty pane and answers "\n" (the /net/tcp/clone idiom) +/status pid, version and pane count +/look write a line: a right click on it at the active pane; read: the serials it touched +/exec write a line: a middle click; read the same serials /log one line per editor event: new|del|rename|save ; reads park /screen rendered screen JSON; frozen per open handle /listeners the session's dial addresses -/pane// name body tag ctl addr data xdata sel errors event, plus pty/{ctl,status,data} +/pane/ create a directory here to open a pane; remove one to close it +/pane// name body tag ctl addr dot limit data xdata sel dirty mark scroll + errors event look exec, plus pty/{ctl,status,data} on terminals /os/ the host filesystem /src/ the editor's embedded sources, only when built with -Dembed-sources=true ``` -Nothing in the tree is created by list, stat, walk or read, except that -reading `/new` makes a pane; that is its whole purpose, so a recursive read -of the tree makes one pane per open of it. - -`/ctl` and `/pane//ctl` take the editor's own command language, two verbs: - -- `look TEXT` is a right click on `TEXT`: a path opens a file, `file:12` jumps - to a line, a directory opens a shell there, a URL opens in the browser. From - `/ctl` the look happens at the active pane; from a pane's `ctl` at that pane. -- `exec TEXT` is a middle click: a command word from `src/builtins.zig` - (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`, `Unmount NAME`, `Dump`, - `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...), or anything else, which - runs in the pane's terminal. - -Both verbs are lowercase; the words after `exec` are the editor's capitalized -commands. A pane's `ctl` also keeps acme's addr verbs (`addr=dot`, `dot=addr`, -`limit=addr`, `clean`, `dirty`, `cleartag`, `get`, `mark`, `nomark`, -`noscroll`, `scroll`, `show`). Every line of a write is checked before any -line runs; a malformed line fails the write with EINVAL. A command that fails -inside the editor is reported on the message row, not as a write error. - -After a write to either `ctl`, reading `/ctl` answers the serials of the panes -the command created, or, when it created none, the pane a look focused or the -pane an exec acted on (even one it closed), one per line. Before any command it answers `pid`, `version` and `panes` lines. +Nothing in the tree is created by list, stat, walk or read. A pane is opened +by Tcreate in `/pane` (`mkdir`) and closed by Tremove on `/pane/` (`rmdir`), +which is the only create and the only remove the tree serves. The name a +create asks for is ignored, since a pane is named by the serial the editor +gives it: the Rcreate qid names the new directory, and because `/index` is +ordered by serial its last line is the pane just made. + +`/look` and `/exec` are the editor's two clicks, one per line of a write: + +- a line written to `look` is a right click on it: a path opens a file, + `file:12` jumps to a line, a directory opens a shell there, a URL opens in + the browser. +- a line written to `exec` is a middle click: a command word from + `src/builtins.zig` (`Save`, `Del`, `New`, `Newcol`, `Mount NAME DIAL`, + `Unmount NAME`, `Dump`, `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...), + or anything else, which runs in the pane's terminal. + +The root's pair clicks at the active pane and `/pane//look` and +`/pane//exec` at that pane. Blank lines are skipped, and every other line +is checked before any of them runs, so a control character fails the whole +write with EINVAL; a command that fails inside the editor is reported on the +message row, not as a write error. Reading any of these files answers the +serials of the panes the last command created, or, when it created none, the +pane a look focused or the pane an exec acted on (even one it closed), one +per line. `/pane//name` reads the pane's file name (a terminal's directory) and writing it renames the buffer; a relative name resolves against the pane's -directory. `sel` reads the editor selection and writing it replaces the -selection. `body` appends on write and replaces on truncating open. `addr` -selects a range and `data` or `xdata` read or replace it. `errors` appends to -the directory's `+Errors` pane. Holding `event` open redirects the pane's Look -and Exec clicks to that client; writing a record back performs the action. - -Stats report real lengths for `index`, `ctl`, `name`, `body`, `tag` and `sel`, -modes 0644/0666 (0444 for read-only files, 0222 for write-only), the pane's -last edit time or the process start as mtime, and stable qids. Directory -entries carry no sizes; stat the entry. +directory. `body` appends on write and replaces on truncating open. `sel` +reads the selected text and writing it replaces the selection. `errors` +appends to the directory's `+Errors` pane. Holding `event` open redirects the +pane's Look and Exec clicks to that client; writing a record back performs the +action. `ctl` reads acme's window status line — serial, tag length, body +length, a reserved zero, the dirty flag, the width in cells, the font and the +tab width — and takes one verb, `get`, which reloads the buffer from the name +it carries. + +The three range files `addr`, `dot` and `limit` each read the pair of offsets +they also accept, so copying one onto another is all that acme's `addr=dot`, +`dot=addr` and `limit=addr` ever were. A write is either that pair or an +address expression (`#0,#5`, `/pattern/`, `2+1`); `addr` selects what `data` +and `xdata` read or replace, `dot` is the editor's own selection and moving it +scrolls the pane into view, and `limit` bounds a search and reads empty until +it is set. Truncating a range file empties it; truncating `limit` lifts it. + +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 +an undo point (writing `1` pushes one now), and whether a write scrolls the +pane. Truncating `tag` clears the part of the tag you may edit. + +Stats report real lengths for `index`, `status`, `look`, `exec`, `listeners`, +`name`, `body`, `tag`, `sel`, `ctl`, the range files and the flag files, and +for `log`, `event` and `pty/data` the length of the record a read would +answer, which is zero when nothing is waiting. Modes are 0644/0666 (0444 for +read-only files, 0222 for write-only); mtime is the pane's last edit or the +process start. The qid version of `body`, `data` and `xdata` is the pane's +revision, so a stat sees an edit land without reading the text; every other +file leaves it zero rather than promise a version it cannot keep. Directory +entries carry no sizes, and neither does `/screen`, which has no length until +an open renders its frame; stat the entry. `/log` records `new`, `del`, `rename` and `save` while at least one client holds it open; a read parks until a record arrives. `/screen` returns JSON with @@ -132,8 +163,10 @@ enables the option by default, so the device can serve its own source. This is a control filesystem, not a complete POSIX export. Native filenames may contain up to 255 bytes. Existing regular OS files support read, write, -and truncation to zero; protocol create, remove, rename, and other metadata -changes are refused. Ownership and permissions under `/os` are synthetic. +and truncation to zero; under `/os` protocol create, remove, rename and other +metadata changes are refused, as is every create and remove in the control +tree but the pane directories. Ownership and permissions under `/os` are +synthetic. Zero-length truncation accepts the accompanying `mtime` hint sent by Linux v9fs; the hint is not stored. Standalone timestamp changes remain refused. @@ -148,7 +181,8 @@ for Unix and TCP, a poll loop for QUIC, and the 9P client for mounts); `zig build fs-test` drives real sessions using the independent Python client in `test/ninep.py`; `zig build fs-discovery-test` checks that browsing creates -nothing and that `new`, `ctl`, `name`, `sel` and `log` behave. `zig build +nothing, that a pane create and remove work, and that `look`, `exec`, `name`, +`sel` and `log` behave. `zig build 9p-test` checks the two engine configurations' budgets (the engine's own tests are cloud9's `zig build test`); `zig build fs-bench` measures filesystem transactions in the core. diff --git a/docs/v9fs.md b/docs/v9fs.md index 6cb4e998..8cc97ff9 100644 --- a/docs/v9fs.md +++ b/docs/v9fs.md @@ -12,7 +12,7 @@ ls "$PARDES_MOUNT/pane" cat "$PARDES_MOUNT/index" cat "$PARDES_MOUNT/README" cat "$PARDES_MOUNT/pane/$PARDES_PANE/body" -echo 'exec Msg hello' > "$PARDES_MOUNT/ctl" +echo 'Msg hello' > "$PARDES_MOUNT/exec" ``` The mount belongs to that pane's subprocess tree. Other panes and the editor diff --git a/features.txt b/features.txt index 3bfc6967..bee079a7 100644 --- a/features.txt +++ b/features.txt @@ -20,3 +20,51 @@ on tty mode the mouse selection works but the pardes selection on that pane shou between tty panes without changing the pardes semantics or adding anything extra, just neat tricks like this. Still, you might need to come up with something to decrease the number of movements/interactions needed to do simple things like copy and pasting text from a terminal to another. + +The 9p protocol needs to be simplified and made plan9 idiomatic. Stop leaning on ctl verbs: a name should be the operation. The filesystem should carry more +in stat metadata (real sizes, mtimes, qid versions so a client can poll for change cheaply) and use the real 9P operations for what is currently a ctl verb or a +magic read. cloud9's engine already exposes fs.Features{create, remove, wstat} and the editor backend simply never declared them, so /new creating a pane on +*read* becomes a Tcreate in /pane, del becomes Tremove, cleartag becomes a truncating write to tag, and clean/dirty, scroll/noscroll, mark/nomark and +addr=dot/dot=addr/limit=addr become their own files. What is left of ctl should be only the verbs with no file analog. + +shift-esc stopped leaving raw tty mode on the sdl backend. Commit a624a56 moved raw tty keys to an early-return branch that only knew ctrl-B and bare Esc, so +shift-esc was forwarded to the child instead. It is the unconditional way out of tty mode and should stay that way. + +On tty mode scrolling should work and not always move the focus down to follow a new line. The terminal only moves down to follow new output when the last line +is already at the bottom, like most terminals and editors. Typing and entering raw mode snap back to live output, so nobody types blind. + +The different kinds of layout pardes can initialize (tty mode, file mode, the classic three-shell boot, and the others that already exist) should be an explicit +enum rather than a chain of ifs over tty_only/shells/file/missing. That part of the code needs to be more explicit. The bare tty startup should always start with +an empty text pane at the bottom, not focused. + +Nested pardes detection stopped working. Spawning a subprocess should export an env variable holding the pardes pid, so a child can see it and call a Look on the +parent through the 9p mount instead of starting a second session; whether 9p is mounted is another env variable the child can check. + +Builtins should call Msg with the builtin name when they are called, minus a few exceptions like Msg itself. This is gated on a verbose config that is on by +default and can be turned off. + +The sr.ht upstream has a macos update pushed to it. Pull it, merge it into the tip here as its own change, then look at what the macos backend gained and port +those features to the sdl backend wherever it makes sense. + +Config options should print symmetrically with the way they are set: instead of showing transparency with a colon and a percent sign it should read like +`Etcetera 70`, exactly what you would type to set it, and the same for every other setting. LocationsConfig should only open an output window with the current +config when it is called with no arguments. + +Closing the last pane in a column should not delete the column: it should leave a new empty pane there instead. A column closes only when it is closed explicitly, +so if there is no builtin for that, add a Delcol that sits on the column tagline by default. And in every tagline the builtin that kills or closes the thing should +come last, which does not look like the case for panes right now. + +Pasting regressed on the terminal: inside agent sessions text is being pasted as an image and simply does not work. Reproduce it first, then fix it. + +The 9p skill documentation should teach interacting with a running pardes through the /mnt/9p mount with ordinary file tools — cat, echo, ls — as the normal +ad-hoc path, and fall back to the python client only when nothing is mounted. + +Dead sessions are never removed from the posted-9P registry, which is a bug. A session that aborts or is killed leaves both its socket in $XDG_RUNTIME_DIR and its +symlink under the registry directory, so /mnt/9p/pardes lists entries that give Input/output error on access. postToRegistry only ever replaces its own name's +entry; nothing sweeps the rest. Posting should sweep the service directory and unlink every entry whose target refuses a connection, and the dangling socket with +it. cloud9 already classifies a refused connect as stale (post.zig Probe), and 9p_io.zig already has `alive`, which treats uncertainty as live -- that is the right +bias, so only a definite refusal reaps. + +After all of the above, do an optimization pass on startup time for the gui and tty platforms: measure first, then optimize. build.zig already has a `perf` step +(gesture latency and bounded terminal stress, with --json and a --base baseline to compare against), so a startup measurement belongs there rather than in a new +harness, and the baseline files are how a regression gets caught later. diff --git a/src/9p_io.zig b/src/9p_io.zig index 4f7c2e97..3d019e0d 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -596,7 +596,7 @@ pub const Listener = struct { } for (l.control) |fd| { setCloexec(fd); - setNonblock(fd); + _ = setNonblock(fd); } l.arm(); l.watcher = try std.Thread.spawn(.{}, watchQuic, .{l}); @@ -671,6 +671,7 @@ pub const Listener = struct { log.warn("registry post skipped: cannot create {s}", .{svc}); return; } + sweepRegistry(l.io, svc); var entry_buf: [sun_path_len:0]u8 = undefined; const entry = std.fmt.bufPrintSentinel(&entry_buf, "{s}/{s}", .{ svc, name }, 0) catch { log.warn("registry post skipped: name too long: {s}", .{name}); @@ -679,15 +680,15 @@ pub const Listener = struct { const target = l.path_buf[0..l.path_len]; // Replace only what is provably not live: our own entry, or a - // dead predecessor's symlink. `isListening` treats uncertainty - // as live, so it is only asked about an entry that *is* a - // symlink; anything else is left strictly alone and the - // symlink below simply fails. + // dead predecessor's symlink. `probe` treats uncertainty as + // live, so it is only asked about an entry that *is* a symlink; + // anything else is left strictly alone and the symlink below + // simply fails. var link_buf: [sun_path_len]u8 = undefined; const n = libc.readlink(entry, &link_buf, link_buf.len); if (n >= 0) { const had = link_buf[0..@intCast(n)]; - if (!std.mem.eql(u8, had, target) and alive(entry)) { + if (!std.mem.eql(u8, had, target) and probe(entry) == .live) { log.warn("registry entry pardes/{s} is live; not re-posted", .{name}); return; } @@ -842,12 +843,129 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, named: []const u8, fallback: [ const alive = transport.isListening; -fn setNonblock(fd: c_int) void { +/// What is at a socket path, in cloud9's three answers (`post.Probe`). +/// Asked here with libc rather than through `cloud9.post.probe`, whose +/// raw Linux syscalls darwin cannot compile, but the classification is +/// that file's and must not become a second opinion: the one definite +/// refusal is `.stale`, nothing at the path at all is `.none`, and +/// everything else — connected, busy, refused permission, a surprise — +/// is `.live`. Uncertainty belongs to the server that owns the socket, +/// never to a sweeper deciding what to delete. +const Probe = enum { none, stale, live }; + +/// `SOCK.STREAM`, and the kernel's own non-blocking bit where there is +/// one. Darwin's `SOCK.NONBLOCK` is a Zig shim for `std.posix.socket` +/// to unpack, not an ABI value, so handing it to the raw libc call +/// would ask a kernel that has never heard of it; there it is an +/// `fcntl` instead. +const probe_socket_kind: c_uint = libc.SOCK.STREAM | (if (darwin) 0 else libc.SOCK.NONBLOCK); + +fn probe(path: [:0]const u8) Probe { + if (path.len + 1 > sun_path_len) return .live; // cannot ask; assume occupied + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + const fd = libc.socket(libc.AF.UNIX, probe_socket_kind, 0); + if (fd < 0) return .live; + defer _ = libc.close(fd); + + // Non-blocking is the whole safety of this function, so it is read + // back rather than assumed: a BLOCKING connect to a live server + // whose backlog is full parks in the kernel with no timeout to end + // it — measured, it simply never returns — and a sweep that parks + // takes the editor's startup with it. An fd that cannot be proven + // non-blocking is never connected at all, which lands on `.live`, + // the answer that deletes nothing. + if (comptime darwin) _ = setNonblock(fd); + if (!isNonblocking(fd)) return .live; + + if (libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0) return .live; + // cloud9's `post.probe` mapping, answer for answer. The connection + // is never wanted: EAGAIN (a full Unix backlog) and EINPROGRESS say + // somebody is listening, which is all that was asked, and the fd + // closes without ever waiting for it to become writable. + return switch (libc.errno(-1)) { + .AGAIN, .INPROGRESS, .PERM, .ACCES => .live, + .CONNREFUSED => .stale, + .NOENT, .NOTDIR => .none, + else => .live, + }; +} + +/// Reaps the registry entries whose editor is gone, once per post. +/// +/// A clean exit unposts itself (`unpostFromRegistry`), so everything +/// left behind comes from an exit that could not: an aborted test, a +/// kill, a crash. No code in the dead process can ever run, so the cure +/// has to be somebody else's readdir, and the next editor to start is +/// the somebody. It is only ever a symlink that is followed or +/// unlinked, because that is the only thing `postToRegistry` makes and +/// anything else under a name belongs to whoever put it there; and only +/// a definite refusal counts as gone. The socket a stale entry points +/// at goes too, but not before a stat agrees it is a socket of ours: a +/// plain file answers a connect with the same refusal. +fn sweepRegistry(io: std.Io, svc: [:0]const u8) void { + if (comptime !supported) return; + + // The names are staged before anything is unlinked, so the sweep + // never asks a directory to keep reading while it is being edited. + var names: [4096]u8 = undefined; + var staged: usize = 0; + { + const dir = std.Io.Dir.openDirAbsolute(io, svc, .{ .iterate = true }) catch return; + defer dir.close(io); + var read_buf: [std.Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var reader: std.Io.Dir.Reader = .init(dir, &read_buf); + while (true) { + const listed = (reader.next(io) catch break) orelse break; + if (listed.name.len == 0 or listed.name.len > 255) continue; + if (staged + 1 + listed.name.len > names.len) break; + names[staged] = @intCast(listed.name.len); + @memcpy(names[staged + 1 ..][0..listed.name.len], listed.name); + staged += 1 + listed.name.len; + } + } + + var reaped: usize = 0; + var i: usize = 0; + while (i < staged) { + const name = names[i + 1 ..][0..names[i]]; + i += 1 + name.len; + var entry_buf: [sun_path_len:0]u8 = undefined; + const entry = std.fmt.bufPrintSentinel(&entry_buf, "{s}/{s}", .{ svc, name }, 0) catch continue; + const facts = statNoFollow(entry) orelse continue; + if (facts.mode & 0o170000 != 0o120000) continue; // not a symlink: not ours to judge + const state = probe(entry); + if (state == .live) continue; + var link_buf: [sun_path_len]u8 = undefined; + const n = libc.readlink(entry, &link_buf, link_buf.len); + if (libc.unlink(entry) != 0) continue; + reaped += 1; + // A relative target would resolve against this editor's working + // directory, which says nothing about what the entry named. + if (state != .stale or n <= 0 or link_buf[0] != '/') continue; + var target_buf: [sun_path_len:0]u8 = undefined; + const target = std.fmt.bufPrintSentinel(&target_buf, "{s}", .{link_buf[0..@intCast(n)]}, 0) catch continue; + const t = statNoFollow(target) orelse continue; + if (t.mode & 0o170000 == 0o140000 and t.uid == libc.getuid()) _ = libc.unlink(target); + } + if (reaped != 0) log.info("reaped {d} stale registry entries under {s}", .{ reaped, svc }); +} + +/// Whether it worked, because `probe` is not allowed to find out the +/// hard way: a caller that needs the guarantee has to be able to check. +fn setNonblock(fd: c_int) bool { const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); - if (flags < 0) return; + if (flags < 0) return false; var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); o.NONBLOCK = true; - _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); + return libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))) >= 0; +} + +fn isNonblocking(fd: c_int) bool { + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return false; + const o: libc.O = @bitCast(@as(u32, @bitCast(flags))); + return o.NONBLOCK; } const testing = std.testing; @@ -866,6 +984,108 @@ test "a name that is not one path component is no address at all" { try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); } +test "the registry sweep takes the dead entries and leaves everything else" { + if (comptime !supported) return error.SkipZigTest; + // Under the real runtime directory, because a sun_path is 108 bytes + // and the test cache's temporary directories are longer than that. + var dir_buf: [sun_path_len:0]u8 = undefined; + const base = socketDir(&dir_buf) orelse return error.SkipZigTest; + if (!ensureSocketDir(base)) return error.SkipZigTest; + + var svc_buf: [sun_path_len:0]u8 = undefined; + const svc = std.fmt.bufPrintSentinel(&svc_buf, "{s}/sweep-{d}", .{ base, @as(u32, @intCast(libc.getpid())) }, 0) catch + return error.SkipZigTest; + if (libc.mkdir(svc, 0o700) != 0) return error.SkipZigTest; + + var paths: [5][sun_path_len:0]u8 = undefined; + const live_sock = try std.fmt.bufPrintSentinel(&paths[0], "{s}/live.sock", .{svc}, 0); + const dead_sock = try std.fmt.bufPrintSentinel(&paths[1], "{s}/dead.sock", .{svc}, 0); + const live = try std.fmt.bufPrintSentinel(&paths[2], "{s}/live", .{svc}, 0); + const dead = try std.fmt.bufPrintSentinel(&paths[3], "{s}/dead", .{svc}, 0); + const stranger = try std.fmt.bufPrintSentinel(&paths[4], "{s}/stranger", .{svc}, 0); + defer { + for ([_][:0]const u8{ live_sock, dead_sock, live, dead, stranger }) |p| _ = libc.unlink(p); + _ = libc.rmdir(svc); + } + + const listening = bindSocket(live_sock); + try testing.expect(listening >= 0); + defer _ = libc.close(listening); + try testing.expectEqual(@as(c_int, 0), libc.listen(listening, 1)); + + // Bound and then dropped: the file stays, and nobody answers it — + // exactly what an aborted test leaves behind. + const abandoned = bindSocket(dead_sock); + try testing.expect(abandoned >= 0); + _ = libc.close(abandoned); + + try testing.expectEqual(@as(c_int, 0), libc.symlink(live_sock, live)); + try testing.expectEqual(@as(c_int, 0), libc.symlink(dead_sock, dead)); + // Not a symlink, so not this program's to reason about, even though + // connecting to it is refused exactly like the dead socket. + try std.Io.Dir.cwd().writeFile(testing.io, .{ .sub_path = stranger, .data = "" }); + + // The guarantee itself, asserted rather than inferred from the fact + // that the test finished: if a probe socket ever stops being + // non-blocking the connect below parks instead of failing, and a + // parked test costs whoever is building far more than a red one. + const checking = libc.socket(libc.AF.UNIX, probe_socket_kind, 0); + try testing.expect(checking >= 0); + if (comptime darwin) try testing.expect(setNonblock(checking)); + try testing.expect(isNonblocking(checking)); + _ = libc.close(checking); + + // The listener's backlog is filled before anything is asked, because + // a full backlog is the one place a blocking connect parks forever + // (`unix_wait_for_peer`, no timeout) and a sweep that only answers + // while nobody is queued is the sweep that hangs a build. Nothing + // below accepts any of these, so the queue stays full throughout. + var queued: [8]c_int = @splat(-1); + defer for (queued) |fd| { + if (fd >= 0) _ = libc.close(fd); + }; + for (&queued) |*slot| { + const fd = libc.socket(libc.AF.UNIX, probe_socket_kind, 0); + if (fd < 0) break; + if (comptime darwin) _ = setNonblock(fd); + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. live_sock.len + 1], live_sock[0 .. live_sock.len + 1]); + _ = libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))); + slot.* = fd; + } + + const started = transport.nowMs(); + try testing.expectEqual(Probe.live, probe(live)); + try testing.expectEqual(Probe.stale, probe(dead)); + + sweepRegistry(testing.io, svc); + + // Promptly, and not "eventually": a blocking probe never comes back + // at all, so any wall-clock bound at all is the assertion that + // matters. A second is several thousand times what three connects + // and a readdir cost. + try testing.expect(transport.nowMs() - started < 1000); + + try testing.expect(statNoFollow(dead) == null); + try testing.expect(statNoFollow(dead_sock) == null); + try testing.expect(statNoFollow(live) != null); + try testing.expect(statNoFollow(live_sock) != null); + try testing.expect(statNoFollow(stranger) != null); + try testing.expectEqual(Probe.live, probe(live)); +} + +fn bindSocket(path: [:0]const u8) c_int { + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return fd; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = libc.close(fd); + return -1; + } + return fd; +} + test "one connection's buffers are sized from the one msize constant" { try testing.expect(msize >= ninep.min_msize); try testing.expectEqual(@as(u32, msize), Runner.msize); @@ -1102,24 +1322,41 @@ pub fn start(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listene return listener; } -pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, forward_look: bool) void { - _ = unsetenv("PARDES_FORWARD_LOOK"); +/// What a pane shell is told about the editor above it, set into this +/// process's environment just before `forkpty` so the child inherits it. +/// +/// Two independent facts, and they are separate variables because they answer +/// separate questions. `PARDES_PID` says "you are inside this editor, and it +/// will take a Look from you": a session whose 9P listener never came up still +/// owns its children, so the child says so rather than looking, from its own +/// side, exactly like no pardes at all. `PARDES_9P` and `PARDES_PANE` say how +/// to reach it, and a `--nested` session exports them too — its socket stays +/// open to scripts and to mounted shells — while withholding `PARDES_PID`, +/// which is the whole of what `--nested` means. +pub fn exportPaneEnv(listener: ?*const Listener, serial: u32, adopts: bool) void { + var announced = false; + if (adopts) announcing: { + var buf: [16]u8 = undefined; + const text = std.fmt.bufPrintSentinel(&buf, "{d}", .{@as(u32, @intCast(libc.getpid()))}, 0) catch + break :announcing; + announced = setenv("PARDES_PID", text, 1) == 0; + } + if (!announced) _ = unsetenv("PARDES_PID"); + if (listener) |l| exporting: { var sock: [sun_path_len]u8 = undefined; const path = std.fmt.bufPrintSentinel(&sock, "{s}", .{l.path()}, 0) catch break :exporting; var buf: [16]u8 = undefined; const id = std.fmt.bufPrintSentinel(&buf, "{d}", .{serial}, 0) catch break :exporting; if (setenv("PARDES_9P", path, 1) != 0) break :exporting; - if (setenv("PARDES_PANE", id, 1) != 0) break :exporting; - if (setenv("PARDES_FORWARD_LOOK", if (forward_look) "1" else "0", 1) == 0) return; + if (setenv("PARDES_PANE", id, 1) == 0) return; } _ = unsetenv("PARDES_9P"); _ = unsetenv("PARDES_PANE"); - _ = setenv("PARDES_FORWARD_LOOK", "0", 1); } -test "9P shell environment preserves identity when nested Look forwarding is disabled" { - const names = [_][*:0]const u8{ "XDG_RUNTIME_DIR", "HOME", "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }; +test "9P shell environment states being inside pardes apart from how to reach it" { + const names = [_][*:0]const u8{ "XDG_RUNTIME_DIR", "HOME", "PARDES_PID", "PARDES_9P", "PARDES_PANE" }; var saved: [names.len]?[:0]u8 = @splat(null); for (names, &saved) |name, *value| { if (libc.getenv(name)) |old| value.* = try testing.allocator.dupeZ(u8, std.mem.span(old)); @@ -1146,17 +1383,32 @@ test "9P shell environment preserves identity when nested Look forwarding is dis const path = "/tmp/pardes-example.sock"; @memcpy(listener.path_buf[0..path.len], path); listener.path_len = path.len; + var own: [16]u8 = undefined; + const own_pid = try std.fmt.bufPrint(&own, "{d}", .{@as(u32, @intCast(libc.getpid()))}); + + // A `--nested` session: reachable for scripts and mounts, but nobody's + // parent, so a pardes started in one of its shells runs a session of + // its own instead of handing its argument over. exportPaneEnv(listener, 7, false); try testing.expectEqualStrings(path, std.mem.span(libc.getenv("PARDES_9P").?)); try testing.expectEqualStrings("7", std.mem.span(libc.getenv("PARDES_PANE").?)); - try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + try testing.expect(libc.getenv("PARDES_PID") == null); + exportPaneEnv(listener, 8, true); try testing.expectEqualStrings("8", std.mem.span(libc.getenv("PARDES_PANE").?)); - try testing.expectEqualStrings("1", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + try testing.expectEqualStrings(own_pid, std.mem.span(libc.getenv("PARDES_PID").?)); + + // A session whose listener never came up is still the session this shell + // is inside: the pid stands on its own, and only the address is missing. + exportPaneEnv(null, 0, true); + try testing.expect(libc.getenv("PARDES_9P") == null); + try testing.expect(libc.getenv("PARDES_PANE") == null); + try testing.expectEqualStrings(own_pid, std.mem.span(libc.getenv("PARDES_PID").?)); + exportPaneEnv(null, 0, false); try testing.expect(libc.getenv("PARDES_9P") == null); try testing.expect(libc.getenv("PARDES_PANE") == null); - try testing.expectEqualStrings("0", std.mem.span(libc.getenv("PARDES_FORWARD_LOOK").?)); + try testing.expect(libc.getenv("PARDES_PID") == null); } pub const Client = struct { diff --git a/src/builtins.zig b/src/builtins.zig index 39d70e5d..aa2b2d6d 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -137,6 +137,14 @@ pub const registry = struct { unreachable; } + /// Does this builtin say its own name on the message row before it runs? + /// The ones that own that row themselves declare `quiet` and do not. + pub fn announces(b: Builtin()) bool { + inline for (manualBuiltinList(), 0..) |T, i| + if (@intFromEnum(b) == i) return !(@hasDecl(T, "quiet") and T.quiet); + return true; // a setting builtin changes something invisible; say so + } + pub fn dispatch(b: Builtin(), c: Ctx) void { inline for (manualBuiltinList(), 0..) |T, i| if (@intFromEnum(b) == i) return T.run(c); @@ -259,6 +267,7 @@ pub const Unmount = struct { pub const Msg = struct { pub const takes_arg = true; + pub const quiet = true; // it IS the message row pub fn run(c: Ctx) void { if (c.arg) |text| c.p.setMessage(c.id, text) @@ -542,11 +551,15 @@ pub const LocationsConfig = struct { pub const output: OutputTraits = .{ .name = "+LocationsConfig" }; pub fn run(c: Ctx) void { - const next = c.p.locations_config.parse(c.arg orelse "") catch |err| { + const argument = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); + const next = c.p.locations_config.parse(argument) catch |err| { c.p.reportError(c.id, "LocationsConfig", err); return; }; c.p.locations_config = next; + // Bare, it is a question, and the window is the answer. With arguments + // it is a setting, and a setting that opens a window is noise. + if (argument.len != 0) return; report(c) catch |err| c.p.reportError(c.id, "LocationsConfig", err); } diff --git a/src/config.zig b/src/config.zig index 440549b3..3169b20c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -71,6 +71,7 @@ pub const leader_path = paths: { .FocusTint = null, .ColumnTags = null, .SyntaxBold = null, + .Verbose = null, .TreeContextTagStyle = null, .Wrap = "tw", .Tagbottom = "tb", @@ -224,10 +225,10 @@ pub const tty_filter_min_contrast: f64 = 1.5; pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; -pub const pane_builtins_str = "Tty Del Collapse"; -pub const pdf_pane_builtins_str = "Tty Del PdfSections PdfTint Collapse"; -pub const file_pane_builtins_str = "Save Tty Del Collapse"; -pub const terminal_pane_builtins_str = "Tty Save Del Mode Filter Collapse"; +pub const pane_builtins_str = "Tty Collapse Del"; +pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del"; +pub const file_pane_builtins_str = "Save Tty Collapse Del"; +pub const terminal_pane_builtins_str = "Tty Save Mode Filter Collapse Del"; // Each mode badge is one codepoint. pub const tag_normal = " "; @@ -540,6 +541,8 @@ pub const Runtime = struct { focus_tint: bool = true, column_tags: bool = true, syntax_bold: bool = false, + /// Builtins announce themselves on the message row. + verbose: bool = true, window_opacity: u8 = 100, window_blur: u8 = 0, window_opacity_pending: bool = true, @@ -637,7 +640,7 @@ pub const Runtime = struct { pub const Capability = std.meta.FieldEnum(Capabilities); - pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose }; pub const Scene = std.meta.FieldEnum(layout.SceneEffect); pub const Action = union(enum) { @@ -682,6 +685,7 @@ pub const Runtime = struct { .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } }, .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } }, .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, + .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "Theme", .action = .theme }, .{ .word = "Shell", .action = .shell }, .{ .word = "Font", .action = .font, .availability = .font_picker }, @@ -761,15 +765,10 @@ pub const Runtime = struct { pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { switch (setting.action) { + // Every toggle names a bool field of its own, so one line covers + // all of them and a new toggle needs no arm here. .toggle => |field| switch (field) { - .colors => state.colors = !state.colors, - .wrap => state.wrap = !state.wrap, - .tree_context_tag_style => state.tree_context_tag_style = !state.tree_context_tag_style, - .tag_bottom => state.tag_bottom = !state.tag_bottom, - .debug => state.debug = !state.debug, - .focus_tint => state.focus_tint = !state.focus_tint, - .column_tags => state.column_tags = !state.column_tags, - .syntax_bold => state.syntax_bold = !state.syntax_bold, + inline else => |f| @field(state, @tagName(f)) = !@field(state, @tagName(f)), }, .shell => { const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); @@ -835,46 +834,42 @@ pub const Runtime = struct { return findAction(.{ .transition = transition }).?.word; } + /// Each line reads as the command that would set it: the word, then the + /// value you would type after it. No colons, no units — `WindowOpacity 70` + /// is both what Config prints and what you write back. pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { const state = context.state; var wrote_transition = false; for (settings) |setting| switch (setting.action) { .toggle => |field| { const value = switch (field) { - .colors => state.colors, - .wrap => state.wrap, - .tree_context_tag_style => state.tree_context_tag_style, - .tag_bottom => state.tag_bottom, - .debug => state.debug, - .focus_tint => state.focus_tint, - .column_tags => state.column_tags, - .syntax_bold => state.syntax_bold, + inline else => |f| @field(state, @tagName(f)), }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); + try out.print("{s} {s}\n", .{ setting.word, onOff(value) }); }, - .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), + .theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }), .window_opacity => { if (setting.enabled(context.capabilities)) - try out.print("{s}: {d}%{s}\n", .{ + try out.print("{s} {d}{s}\n", .{ setting.word, state.window_opacity, if (state.window_opacity_pending) " (pending)" else "", }) else - try out.print("{s}: unsupported\n", .{setting.word}); + try out.print("{s} unsupported\n", .{setting.word}); }, .window_blur => { if (setting.enabled(context.capabilities)) - try out.print("{s}: {d}%\n", .{ setting.word, state.window_blur }) + try out.print("{s} {d}\n", .{ setting.word, state.window_blur }) else - try out.print("{s}: unsupported\n", .{setting.word}); + try out.print("{s} unsupported\n", .{setting.word}); }, .shell => { const chosen = state.shell.requested.get(); try out.print( - "{s} requested (new panes): {s}{s}\n" ++ - "{s} effective (last spawn): {s}\n" ++ - "{s} pending: {s}\n", + "{s} requested (new panes) {s}{s}\n" ++ + "{s} effective (last spawn) {s}\n" ++ + "{s} pending {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen, @@ -887,18 +882,18 @@ pub const Runtime = struct { ); }, .font => if (!setting.enabled(context.capabilities)) - try out.print("{s}: unsupported\n", .{setting.word}) + try out.print("{s} unsupported\n", .{setting.word}) else { if (state.font.requested_size_hundredths == 0) - try out.print("{s} requested size: unchanged\n", .{setting.word}) + try out.print("{s} requested size unchanged\n", .{setting.word}) else - try out.print("{s} requested size: {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 }); + try out.print("{s} requested size {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 }); try out.print( - "{s} requested: {s}\n" ++ - "{s} requested path: {s}\n" ++ - "{s} effective: {s}\n" ++ - "{s} pending: {s}\n" ++ - "{s} effective size: {d}.{d:0>2} {s}\n", + "{s} requested {s}\n" ++ + "{s} requested path {s}\n" ++ + "{s} effective {s}\n" ++ + "{s} pending {s}\n" ++ + "{s} effective size {d}.{d:0>2} {s}\n", .{ setting.word, shown(state.font.requested_name.get()), @@ -916,28 +911,28 @@ pub const Runtime = struct { ); }, .tagline_size => if (!context.capabilities.tagline_font_size) - try out.print("{s}: unsupported\n", .{setting.word}) + try out.print("{s} unsupported\n", .{setting.word}) else if (!setting.enabled(context.capabilities)) - try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) + try out.print("{s} {d} (build-time only)\n", .{ setting.word, state.font.tagline_percent }) else - try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), + try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }), .transition => { if (wrote_transition) continue; wrote_transition = true; if (!setting.enabled(context.capabilities)) - try out.writeAll("Panel transition: unsupported\n") + try out.writeAll("Panel transition unsupported\n") else - try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); + try out.print("Panel transition {s}\n", .{transitionSettingName(state.panel_transition)}); }, .scene => |effect| { if (!setting.enabled(context.capabilities)) { - try out.print("{s}: unsupported\n", .{setting.word}); + try out.print("{s} unsupported\n", .{setting.word}); continue; } const enabled = switch (effect) { inline else => |field| @field(state.scene_effects, @tagName(field)), }; - try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); + try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) }); }, }; @@ -1073,7 +1068,8 @@ pub const Runtime = struct { test "Config report observes every simple setting and all live context" { var state: Runtime = .{}; - var storage: [4096]u8 = undefined; + // Every setting, every context line: the report outgrows 4K. + var storage: [8192]u8 = undefined; const context: ReportContext = .{ .startup_config_path = "/tmp/pardes/init", .platform = "gui", @@ -1112,37 +1108,38 @@ pub const Runtime = struct { const report = storage[0..out.end]; const expected = switch (setting.action) { .toggle => |field| switch (field) { - .colors => "Colors: off\n", - .wrap => "Wrap: off\n", - .tree_context_tag_style => "TreeContextTagStyle: off\n", - .tag_bottom => "Tagbottom: on\n", - .debug => "Debug: on\n", - .focus_tint => "FocusTint: off\n", - .column_tags => "ColumnTags: off\n", - .syntax_bold => "SyntaxBold: on\n", + .colors => "Colors off\n", + .wrap => "Wrap off\n", + .tree_context_tag_style => "TreeContextTagStyle off\n", + .tag_bottom => "Tagbottom on\n", + .debug => "Debug on\n", + .focus_tint => "FocusTint off\n", + .column_tags => "ColumnTags off\n", + .syntax_bold => "SyntaxBold on\n", + .verbose => "Verbose off\n", }, - .shell => "Shell requested (new panes): fish\n", - .tagline_size => "TaglineSize: 73%\n", - .window_opacity => "WindowOpacity: 85% (pending)\n", - .window_blur => "WindowBlur: 40%\n", + .shell => "Shell requested (new panes) fish\n", + .tagline_size => "TaglineSize 73\n", + .window_opacity => "WindowOpacity 85 (pending)\n", + .window_blur => "WindowBlur 40\n", .transition => |transition| switch (transition) { .off => unreachable, - .slide => "Panel transition: PanelSlide\n", - .zoom => "Panel transition: PanelZoom\n", - .dissolve => "Panel transition: PanelDissolve\n", - .ascii => "Panel transition: PanelAscii\n", - .vertical => "Panel transition: PanelVertical\n", - .edges => "Panel transition: PanelEdges\n", - .fall => "Panel transition: PanelFall\n", - .wave => "Panel transition: PanelWave\n", - .curtain => "Panel transition: PanelCurtain\n", - .scramble => "Panel transition: PanelScramble\n", - .typewriter => "Panel transition: PanelType\n", + .slide => "Panel transition PanelSlide\n", + .zoom => "Panel transition PanelZoom\n", + .dissolve => "Panel transition PanelDissolve\n", + .ascii => "Panel transition PanelAscii\n", + .vertical => "Panel transition PanelVertical\n", + .edges => "Panel transition PanelEdges\n", + .fall => "Panel transition PanelFall\n", + .wave => "Panel transition PanelWave\n", + .curtain => "Panel transition PanelCurtain\n", + .scramble => "Panel transition PanelScramble\n", + .typewriter => "Panel transition PanelType\n", }, .scene => |effect| switch (effect) { - .crt => "Crt: on\n", - .ripple => "Ripple: on\n", - .glitch => "Glitch: on\n", + .crt => "Crt on\n", + .ripple => "Ripple on\n", + .glitch => "Glitch on\n", }, .theme, .font => unreachable, }; @@ -1163,15 +1160,15 @@ pub const Runtime = struct { try writeReport(&out, context); const report = storage[0..out.end]; for ([_][]const u8{ - "Theme: acme\n", - "WindowOpacity: 85%\n", - "Font requested size: 18.50\n", - "Font requested: Wanted Mono\n", - "Font requested path: /fonts/wanted.ttf\n", - "Font effective: Effective Mono\n", - "Font pending: on\n", - "Font effective size: 13.75 points\n", - "TaglineSize: 82%\n", + "Theme acme\n", + "WindowOpacity 85\n", + "Font requested size 18.50\n", + "Font requested Wanted Mono\n", + "Font requested path /fonts/wanted.ttf\n", + "Font effective Effective Mono\n", + "Font pending on\n", + "Font effective size 13.75 points\n", + "TaglineSize 82\n", "Startup config: /tmp/pardes/init\n", "Platform: gui\n", "Compiled default shell: /bin/sh\n", @@ -1197,23 +1194,23 @@ pub const Runtime = struct { out = .fixed(&storage); try writeReport(&out, defaults_context); const defaults_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowOpacity: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowOpacity unsupported\n") != null); try std.testing.expect(!find("WindowOpacity").?.enabled(defaults_context.capabilities)); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur unsupported\n") != null); try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities)); try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities)); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes) /bin/sh (default)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); @@ -1223,8 +1220,8 @@ pub const Runtime = struct { out = .fixed(&storage); try writeReport(&out, defaults_context); const web_report = storage[0..out.end]; - try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100 (build-time only)\n") != null); } }; diff --git a/src/fs-help.txt b/src/fs-help.txt index a0a84b28..1e5d64c2 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -2,41 +2,43 @@ Pardes control filesystem ========================= README this guide index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name -ctl write commands; read the serials the last command made or touched -new reading it creates one empty pane and answers its serial +status pid, version and pane count +look write a line: a right click on it at the active pane; read: the serials it touched +exec write a line: a middle click, an editor command word or a shell line; read the same log one line per editor event (new/del/rename/save ); reads wait screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses -pane// name body tag ctl addr data xdata sel errors event, and pty/ for terminals +pane/ mkdir makes a pane, rmdir closes it +pane// name body tag ctl addr dot limit data xdata sel dirty mark scroll + errors event look exec, and pty/ for terminals os/ the host filesystem src/ the editor's own sources, only in a -Dembed-sources=true build -Commands, on /ctl (applied at the active pane) or on pane//ctl (at that pane): - look TEXT a right click: open a file, a file:12 location, a directory, a URL - exec TEXT a middle click: an editor command word (Save Del New Newcol Mount Unmount - Dump Restore Msg Find Grep Tty ...), or a shell command for the pane's terminal Below, $m is the mount point (PARDES_MOUNT in a Tty9p shell; 9ns and 9p work too): cat $m/index which panes exist - n=$(cat $m/new) make a pane, remember its serial - echo 'look /etc/hosts:3' > $m/ctl; cat $m/ctl open a file, see the pane it went to - printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first) + mkdir $m/pane/x; n=$(awk 'END{print $1}' $m/index) make a pane, take its serial + echo /etc/hosts:3 > $m/look; cat $m/look open a file, see the pane it went to + printf 'text\n' > $m/pane/$n/body append to a pane (>| truncates first) cat $m/pane/$n/name; echo notes.txt > $m/pane/$n/name read, then rename - echo 'exec Save' > $m/pane/$n/ctl save it (exec Del closes it) - echo 'exec Msg hello' > $m/ctl show text in the editor + echo Save > $m/pane/$n/exec save it; rmdir $m/pane/$n closes it + echo 'Msg hello' > $m/exec show text in the editor echo '#0,#5' > $m/pane/$n/addr; echo NEW > $m/pane/$n/data replace bytes 0..5 - echo 'dot=addr' > $m/pane/$n/ctl; cat $m/pane/$n/sel select the range, read it + cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it + cat $m/pane/$n/dirty; echo 0 > $m/pane/$n/dirty is it modified? say it is not cat $m/log block until a pane is made, renamed, saved or closed cat $m/pane/$n/event hold it open to own that pane's clicks - echo 'exec' > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT + echo exec > $m/pane/$n/pty/ctl restart a shell; also winsize C R, sig INT Pitfalls, one each: - Every open of new makes another pane, so `cat $m/new` twice makes two. - ls, stat and find create nothing; reading log, event or pty/data blocks until something happens. - exec Del closes a pane even when it is dirty; index shows the dirty flag first. - look and exec are lowercase verbs; the words after exec are the editor's capitalized commands. - ctl also takes acme's addr verbs (addr=dot dot=addr limit=addr clean dirty get show ...), one per line. + Only mkdir in pane/ makes a pane; ls, stat, find and every read create nothing. + Panes are named by the serial the editor gives them, not by the name mkdir asked for. + rmdir closes a pane even when it is dirty; index and pane//dirty show the flag. + addr, dot and limit read the same pair of offsets they take, so cp between them works. + dirty, mark and scroll read "0" or "1" and take "0" or "1"; truncating limit lifts it. Writing body appends; open it with truncation to replace; writes are whole UTF-8 sequences. + Truncating tag clears the part you may edit; truncating dot or addr empties it. A terminal's body is a history snapshot frozen per open; pty/data is the live stream. screen is frozen per open handle as well; reopen it for a fresh frame. - A command that fails is reported in the editor, not as a write error; a malformed line fails the write. + A failing command is reported in the editor, not as a write error; a bad line fails the write. + pane//ctl reads acme's window status line and takes one verb, get, which reloads from disk. diff --git a/src/fs.zig b/src/fs.zig index 7f416440..7cb08980 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -260,12 +260,13 @@ test "unmount refuses pane paths inherited directories and queued save targets" } gpa.free(pane.file.?.path); pane.file.?.path = try gpa.dupe(u8, "/n/peer2/file"); - const shell = try p.newShell(1, "/n/peer/dir"); - p.setCwd(1, "/n/peer/dir"); + const slot = p.freeSlot().?; + const shell = try p.newShell(slot, "/n/peer/dir"); + p.setCwd(slot, "/n/peer/dir"); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); pane.cwd = .{ .inherited = shell }; try std.testing.expectError(error.MountInUse, unmount(p, "peer")); - p.setCwd(1, "/"); + p.setCwd(slot, "/"); while (p.nextEffect()) |_| {} p.emit(.{ .save_text = .{ .pane = 0, .serial = pane.serial, .path = .from("/n/peer/pending") } }); try std.testing.expectError(error.MountInUse, unmount(p, "peer")); @@ -1261,10 +1262,10 @@ pub const Namespace = struct { /// The editor-wide event stream, recorded only while /log is held open. log: tree.events.Queue = .{}, log_readers: u16 = 0, - /// What the last ctl command created or touched, answered by reading /ctl. + /// What the last look or exec created or touched, answered by reading + /// either of those files. results: [MAX_PANES]u32 = undefined, results_len: u8 = 0, - results_valid: bool = false, /// Process start, the mtime of everything that has no edit time of its own. started: u32 = 0, diff --git a/src/host_io.zig b/src/host_io.zig index 212c23b4..fb09fcc7 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -874,6 +874,9 @@ pub fn forkShell( if (fs == null) return error.SessionSocketRequired; break :blk try @import("linux/v9fs.zig").helperPath(&helper_buf); } else null; + // The last argument is whether this session takes its children's Looks — + // every session does but a `--nested` one, and a fork with no core at all + // (the tests below) is nobody's parent. ninep_io.exportPaneEnv( fs, if (core) |c| (if (c.panes[pane]) |pn| pn.serial else 0) else 0, diff --git a/src/main.zig b/src/main.zig index 4b1910bd..1cbdf24c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -112,6 +112,16 @@ const nested_text = \\ ; +/// $PARDES_PID named a live editor, so this shell IS inside one, but the Look +/// never got there. Saying so beats quietly opening the second editor that +/// $PARDES_PID exists to prevent. +const unreachable_text = + \\pardes: this shell is inside pardes, but that session did not take the + \\file. Check that it is still running, or pass --nested to start a second + \\editor in here anyway. + \\ +; + // The browser runtime calls a C main (exported below); everything else keeps // the std.process.Init entry. pub const main = if (is_emscripten) webMain else nativeMain; @@ -260,6 +270,10 @@ fn nativeMain(init: std.process.Init) !void { } } opts.tty_only = explicit_tty or session_only; + // The real bare-tty startup wants the editor half present from the first + // frame, so it names that layout; a core booted for a test names none and + // gets the raw shell by itself. + if (opts.tty_only) opts.boot = .tty; opts.mounts = mounts[0..mounts_len]; if (detach != null and attach != null) badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); @@ -272,31 +286,60 @@ fn nativeMain(init: std.process.Init) !void { if (opts.mounts.len != 0 and attach != null) badArgs(init.io, "--mount configures a session's own core, and --attach has none of its own", .{}); if (!new_session and !opts.nested and detach == null and attach == null) forwarding: { - const enabled = std.c.getenv("PARDES_FORWARD_LOOK") orelse break :forwarding; - if (!std.mem.eql(u8, std.mem.span(enabled), "1")) break :forwarding; - const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :forwarding); - ninep_io.Client.validateDial(dial) catch break :forwarding; - const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :forwarding); - for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :forwarding; - const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :forwarding; - if (serial == 0) break :forwarding; - var ctl_buf: [64]u8 = undefined; - const ctl = try std.fmt.bufPrint(&ctl_buf, "/pane/{d}/ctl", .{serial}); + // Am I inside a pardes at all? That is $PARDES_PID and nothing else — + // one variable, exported by every session that means to take its + // children's Looks, so a session that could not open a socket is still + // recognisably the session this shell is inside. A pid naming no live + // process is a shell that outlived its editor (a daemon started from a + // pane, a stale environment carried into a new terminal), which is not + // nested at all. + const owner = std.mem.span(std.c.getenv("PARDES_PID") orelse break :forwarding); + const pid = std.fmt.parseInt(std.c.pid_t, owner, 10) catch break :forwarding; + if (pid <= 0 or std.c.kill(pid, @enumFromInt(0)) != 0) break :forwarding; + + // Can I reach it? That is the other two, and it is a separate answer. + const Parent = struct { dial: []const u8, serial: u32 }; + const found: ?Parent = reaching: { + const dial = std.mem.span(std.c.getenv("PARDES_9P") orelse break :reaching null); + ninep_io.Client.validateDial(dial) catch break :reaching null; + const pane_text = std.mem.span(std.c.getenv("PARDES_PANE") orelse break :reaching null); + for (pane_text) |byte| if (!std.ascii.isDigit(byte)) break :reaching null; + const serial = std.fmt.parseInt(u32, pane_text, 10) catch break :reaching null; + if (serial == 0) break :reaching null; + break :reaching .{ .dial = dial, .serial = serial }; + }; + const parent = found orelse { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; + // The pane's `look` file: one line, and the line is the clicked text + // 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}); const word = positional orelse { var tag_buf: [64]u8 = undefined; - const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{serial}); - const contents = ninep_io.Client.read(arena, dial, tag, tag) catch break :forwarding; + const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial}); + const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; arena.free(contents); try std.Io.File.stderr().writeStreamingAll(init.io, nested_text); std.process.exit(1); }; + // A word this launch cannot turn into a path is an argument problem + // rather than a nesting one: fall through and let the session that + // starts report it, the way `pardes typo` outside pardes does. if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; var command_buf: [8192]u8 = undefined; - const command = std.fmt.bufPrint(&command_buf, "look {s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; - ninep_io.Client.write(arena, dial, ctl, command) catch break :forwarding; + const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, look, command) catch { + try std.Io.File.stderr().writeStreamingAll(init.io, unreachable_text); + std.process.exit(1); + }; return; } if (positional) |a| { diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig index f7642b9e..566e2364 100644 --- a/src/ninep/addr.zig +++ b/src/ninep/addr.zig @@ -255,17 +255,25 @@ test "the address language, form by form" { try testing.expectEqual(@as(u32, 6), p.fs.panes[0].addr.q0); // the `o` in "two" try testing.expectEqual(@as(u32, 7), p.fs.panes[0].addr.q1); + // limit is its own file: copying addr onto it bounds the search, and + // truncating it lifts the bound again. + const limit = Node.of(serial, .limit); _ = th.wr(p, addr, "1"); - _ = th.wr(p, Node.of(serial, .ctl), "limit=addr\n"); + try testing.expectEqualStrings("", th.rd(p, limit, 0, 64).bytes); + var copied: [64]u8 = undefined; + const pair = th.rd(p, addr, 0, 64).bytes; + @memcpy(copied[0..pair.len], pair); + _ = th.wr(p, limit, copied[0..pair.len]); + try testing.expectEqual(@as(u32, 4), p.fs.panes[0].limit.?.q1); + try testing.expectEqualStrings(" 0 4 ", th.rd(p, limit, 0, 64).bytes); _ = th.wr(p, addr, "#0"); try testing.expectEqual(E.INVAL, th.wr(p, addr, "/three/").errno()); - _ = th.wr(p, Node.of(serial, .ctl), "clean\n"); - _ = th.call(p, .{ .tag = 6, .op = .open, .node = Node.of(serial, .ctl) }); + _ = th.call(p, .{ .tag = 6, .op = .setattr, .node = limit, .truncate = true }); try testing.expect(p.fs.panes[0].limit == null); _ = th.wr(p, addr, "#0"); try testing.expectEqual(Status.ok, th.wr(p, addr, "/three/").reply.status); - for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2", "99", "/a\\" }) |bad| { + for ([_][]const u8{ "zzz", "#", "//", "/nomatch/", "1 2 3", "99", "/a\\" }) |bad| { _ = th.wr(p, addr, "#0"); try testing.expectEqual(E.INVAL, th.wr(p, addr, bad).errno()); } diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index d61ebf75..c213ece5 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -1,6 +1,9 @@ -//! The two ctl files. `look TEXT` is a right click and `exec TEXT` a middle -//! click in the editor's own command language; a pane's ctl adds the addr -//! verbs. Reading /ctl answers the serials the last command made or touched. +//! The command files and the status they answer. Writing a line to `look` is +//! a right click on it and writing one to `exec` a middle click, at the +//! active pane from the root and at that pane from /pane//; reading either +//! answers the serials the last command made or touched. /status reports the +//! editor, and a pane's ctl its acme status line and the one verb, `get`, +//! that no file of its own would say any better. const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); @@ -16,36 +19,6 @@ const Req = tree.Req; const Reply = tree.Reply; const E = tree.E; -const Verb = enum { - @"addr=dot", - clean, - cleartag, - dirty, - @"dot=addr", - get, - @"limit=addr", - mark, - nomark, - noscroll, - scroll, - show, -}; - -fn verbIs(line: []const u8, word: []const u8) bool { - if (!std.mem.startsWith(u8, line, word)) return false; - return line.len == word.len or line[word.len] == ' ' or line[word.len] == '\t'; -} - -/// The text after `look` or `exec`, or null when the line is not that verb. -/// Control characters other than tab are refused. -fn textAfter(line: []const u8, verb: []const u8) ?[]const u8 { - if (!verbIs(line, verb)) return null; - const text = std.mem.trim(u8, line[verb.len..], " \t"); - if (text.len == 0) return ""; - for (text) |c| if (c < ' ' and c != '\t') return ""; - return text; -} - // ---- which panes a command made or touched ---- const Serials = struct { @@ -96,37 +69,49 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { noteResult(p, if (exec) context else activeSerial(p) orelse return); } -fn beginResults(p: *Pardes) void { - p.fs.results_len = 0; - p.fs.results_valid = true; -} - -// ---- /ctl ---- +// ---- look and exec ---- -pub fn writeRoot(p: *Pardes, req: Req) Reply { +/// One command per line, checked whole before any line runs: a line is the +/// clicked text itself, so it may not be empty or carry control characters. +/// A null `serial` clicks at whichever pane is active as each line runs, +/// which is what the root's own look and exec mean; a serial stays put. +pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply { for ([2]bool{ false, true }) |apply| { - if (apply) beginResults(p); + if (apply) p.fs.results_len = 0; var it = std.mem.splitScalar(u8, req.data, '\n'); while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - const exec = verbIs(line, "exec"); - const text = textAfter(line, if (exec) "exec" else "look") orelse return Reply.fail(req.tag, E.INVAL); - if (text.len == 0) return Reply.fail(req.tag, E.INVAL); + const text = std.mem.trim(u8, raw, " \t\r"); + if (text.len == 0) continue; + for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL); if (!apply) continue; - if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); - run(p, p.active, exec, text); + const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active; + if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT); + run(p, id, exec, text); } } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -pub fn rootText(p: *Pardes, buf: []u8) []const u8 { - var w = std.Io.Writer.fixed(buf); - if (p.fs.results_valid) { - for (p.fs.results[0..p.fs.results_len]) |serial| w.print("{d}\n", .{serial}) catch break; - return w.buffered(); +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; + n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len; } + return n; +} + +pub fn readResults(p: *Pardes, req: Req) Reply { + const out = p.fs.stage(p.gpa); + for (p.fs.results[0..p.fs.results_len]) |serial| + out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM); + return tree.stagedReply(p, req); +} + +// ---- /status ---- + +fn statusText(p: *Pardes, buf: []u8) []const u8 { + var w = std.Io.Writer.fixed(buf); var count: usize = 0; for (p.panes) |slot| count += @intFromBool(slot != null); const pid: u32 = if (comptime pardes.hosted) @intCast(std.c.getpid()) else 0; @@ -134,13 +119,17 @@ pub fn rootText(p: *Pardes, buf: []u8) []const u8 { return w.buffered(); } -pub const root_text_cap = @max(MAX_PANES * 11, 64 + pardes.version.len); +pub const status_text_cap = 64 + pardes.version.len; + +pub fn statusLen(p: *Pardes) u64 { + var buf: [status_text_cap]u8 = undefined; + return statusText(p, &buf).len; +} -pub fn readRoot(p: *Pardes, req: Req) Reply { - var buf: [root_text_cap]u8 = undefined; - const text = rootText(p, &buf); +pub fn readStatus(p: *Pardes, req: Req) Reply { + var buf: [status_text_cap]u8 = undefined; const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM); + out.appendSlice(p.gpa, statusText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } @@ -190,74 +179,35 @@ pub fn readPane(p: *Pardes, req: Req, pane: *Pane) Reply { return tree.stagedReply(p, req); } -pub fn writePane(p: *Pardes, req: Req, serial: u32) Reply { - for ([2]bool{ false, true }) |apply| { - if (apply) beginResults(p); - var it = std.mem.splitScalar(u8, req.data, '\n'); - while (it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \t\r"); - if (line.len == 0) continue; - const live = p.paneBySerial(serial) orelse if (apply) break else return Reply.fail(req.tag, E.NOENT); - const errno = paneVerb(p, live, line, apply); - if (errno != 0) return Reply.fail(req.tag, errno); - } +/// `get` is the one thing here that no file of the pane's own would say: it +/// reloads the buffer from the name it carries, wherever that name resolves. +/// Repeating it in one write would only reload the same bytes, so it runs once. +pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { + var asked = false; + var it = std.mem.splitScalar(u8, req.data, '\n'); + while (it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (!std.mem.eql(u8, line, "get")) return Reply.fail(req.tag, E.INVAL); + asked = true; + } + if (asked) { + const errno = get(p, pane); + if (errno != 0) return Reply.fail(req.tag, errno); } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } -fn paneVerb(p: *Pardes, id: usize, line: []const u8, apply: bool) u16 { - const pane = p.panes[id] orelse return E.INVAL; - const pf = &p.fs.panes[id]; - - for ([_]bool{ false, true }) |exec| if (textAfter(line, if (exec) "exec" else "look")) |text| { - if (text.len == 0) return E.INVAL; - if (apply) run(p, id, exec, text); - return 0; +fn get(p: *Pardes, pane: *Pane) u16 { + const f = pane_files.fileOf(pane) orelse return 0; + if (!panes.Output.fileTraits(f.output).saves) return 0; + const bytes = fs.read(p, f.path) catch |err| return switch (err) { + error.FileNotFound => E.NOENT, + else => E.IO, }; - const v = std.meta.stringToEnum(Verb, line) orelse return E.INVAL; - if (!apply) return 0; - - switch (v) { - .@"addr=dot" => pf.addr = pane_files.dotOf(pane), - .@"dot=addr" => { - pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); - pane_files.setDot(pane, pf.addr); - }, - .@"limit=addr" => { - pane_files.clampAddr(pf, pane_files.bodyOf(pane).len); - pf.limit = pf.addr; - }, - .clean => if (pane_files.fileOf(pane)) |f| { - f.saved_revision = f.revision; - }, - .dirty => if (pane_files.fileOf(pane)) |f| { - f.saved_revision = f.revision -% 1; - }, - .cleartag => { - pane.tag_tail_len = 0; - pane.tag_init = true; - }, - .get => if (pane_files.fileOf(pane)) |f| { - if (panes.Output.fileTraits(f.output).saves) { - if (fs.read(p, f.path)) |bytes| { - panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, bytes); - f.saved_revision = f.revision; - } else |err| return switch (err) { - error.FileNotFound => E.NOENT, - else => E.IO, - }; - } - }, - .mark => { - pf.nomark = false; - panes.File.pushUndo(p, pane); - }, - .nomark => pf.nomark = true, - .noscroll => pf.noscroll = true, - .scroll => pf.noscroll = false, - .show => pane_files.showOffset(pane, pane_files.dotOf(pane).q0), - } + panes.File.pushUndo(p, pane); + panes.File.setContent(p, f, bytes); + f.saved_revision = f.revision; return 0; } @@ -272,7 +222,9 @@ const withFile = th.withFile; const serialOf = th.serialOf; const Node = tree.Node; const Status = tree.Status; -const root_ctl = @intFromEnum(tree.TopFile.ctl); +const root_status = @intFromEnum(tree.TopFile.status); +const root_look = @intFromEnum(tree.TopFile.look); +const root_exec = @intFromEnum(tree.TopFile.exec); test "pane ctl read is index's five fields plus width in cells, font and tab width" { const gpa = testing.allocator; @@ -299,37 +251,18 @@ test "pane ctl read is index's five fields plus width in cells, font and tab wid try testing.expectEqualStrings("'it''s'", w.buffered()); } -test "every pane ctl verb, and every refusal" { +test "the pane ctl takes get, and nothing that a file of its own now answers" { const gpa = testing.allocator; const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); - const serial = serialOf(p); - const ctl_node = Node.of(serial, .ctl); - const pane = p.panes[0].?; - const pf = &p.fs.panes[0]; - - try testing.expectEqual(Status.ok, wr(p, ctl_node, "nomark\nnoscroll\ndirty\n").reply.status); - try testing.expect(pf.nomark and pf.noscroll and pane_files.dirtyOf(pane)); - try testing.expectEqual(Status.ok, wr(p, ctl_node, "mark\nscroll\nclean\n").reply.status); - try testing.expect(!pf.nomark and !pf.noscroll and !pane_files.dirtyOf(pane)); - - _ = wr(p, ctl_node, "cleartag\n"); - try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); - - _ = wr(p, Node.of(serial, .addr), "2"); - _ = wr(p, ctl_node, "limit=addr\n"); - try testing.expectEqual(@as(u32, 4), pf.limit.?.q0); - _ = wr(p, ctl_node, "dot=addr\nshow\n"); - try testing.expectEqual(@as(i32, 1), pane.cur_row); - + const ctl_node = Node.of(serialOf(p), .ctl); for ([_][]const u8{ - "menu", "nomenu", "dump echo hi", "dumpdir /tmp", "font Go Mono", "lock", "unlock", "bogus", "DEL", - "name x.txt", "put", "del", "delete", "Look x", "Exec Save", + "menu", "nomenu", "dump echo hi", "font Go Mono", "lock", "bogus", "DEL", + "name x.txt", "put", "del", "delete", "Look x", "Exec Save", "clean", + "dirty", "cleartag", "dot=addr", "addr=dot", "show", "mark", "nomark", + "scroll", "limit=addr", "get x", "look /tmp", "exec Del", }) |bad| try testing.expectEqual(E.INVAL, wr(p, ctl_node, bad).errno()); - - try testing.expect(!pane_files.dirtyOf(pane)); - try testing.expectEqual(E.INVAL, wr(p, ctl_node, "dirty\nbogus\n").errno()); - try testing.expect(!pane_files.dirtyOf(pane)); + try testing.expect(p.paneBySerial(serialOf(p)) != null); } test "exec runs a builtin at the pane and records the pane it acted on" { @@ -338,61 +271,69 @@ test "exec runs a builtin at the pane and records the pane it acted on" { defer p.deinit(); const serial = serialOf(p); const pane = p.panes[0].?; - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Msg from ctl\n").reply.status); - try testing.expectEqualStrings("from ctl", pane.msg[0..pane.msg_len]); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Msg from exec\n").reply.status); + try testing.expectEqualStrings("from exec", pane.msg[0..pane.msg_len]); var expected: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{serial}), rd(p, root_ctl, 0, 4096).bytes); + const own = try std.fmt.bufPrint(&expected, "{d}\n", .{serial}); + try testing.expectEqualStrings(own, rd(p, root_exec, 0, 4096).bytes); + // Either command file reads back what the last one did. + try testing.expectEqualStrings(own, rd(p, Node.of(serial, .look), 0, 4096).bytes); + try testing.expectEqual(@as(u64, own.len), call(p, .{ .tag = 1, .op = .getattr, .node = root_exec }).reply.attr.size); - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec New\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "New\n").reply.status); const made = p.panes[p.active].?; try testing.expect(made.serial != serial); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); - try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .ctl), "exec Del\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(made.serial, .exec), "Del\n").reply.status); try testing.expect(p.paneBySerial(made.serial) == null); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } -test "the root ctl reads editor status until a command runs and validates whole batches" { +test "status reads the editor, and look and exec validate whole batches" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg root\n"); defer p.deinit(); - const status = rd(p, root_ctl, 0, 4096); + const status = rd(p, root_status, 0, 4096); try testing.expectEqual(Status.ok, status.reply.status); var lines = std.mem.tokenizeScalar(u8, status.bytes, '\n'); try testing.expect(std.mem.startsWith(u8, lines.next().?, "pid ")); var expected: [64]u8 = undefined; try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "version {s}", .{pardes.version}), lines.next().?); - try testing.expectEqualStrings("panes 1", lines.next().?); + var live: usize = 0; + for (p.panes) |slot| live += @intFromBool(slot != null); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "panes {d}", .{live}), lines.next().?); try testing.expect(lines.next() == null); - const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_ctl }); + const stat = call(p, .{ .tag = 1, .op = .getattr, .node = root_status }); try testing.expectEqual(@as(u64, status.bytes.len), stat.reply.attr.size); - try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); + try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); + try testing.expectEqual(E.PERM, wr(p, root_status, "anything\n").errno()); + try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); const serial = p.next_serial; - for ([_][]const u8{ "look", "exec", "look \t", "Look /virtual/index", "exec Msg a\x00b", "exec Msg x\nbogus\n", "name x", "put" }) |invalid| { - try testing.expectEqual(E.INVAL, wr(p, root_ctl, invalid).errno()); + for ([_][]const u8{ "Msg a\x00b", "Msg x\nMsg \x01y\n" }) |invalid| { + try testing.expectEqual(E.INVAL, wr(p, root_exec, invalid).errno()); try testing.expectEqual(serial, p.next_serial); } - try testing.expectEqualStrings(status.bytes, rd(p, root_ctl, 0, 4096).bytes); + // An empty write is a no-op, not a command. + try testing.expectEqual(Status.ok, wr(p, root_exec, "\n \n").reply.status); + try testing.expectEqualStrings("", rd(p, root_exec, 0, 4096).bytes); + try testing.expectEqualStrings(status.bytes, rd(p, root_status, 0, 4096).bytes); const pane = p.panes[0].?; - try testing.expectEqual(Status.ok, wr(p, root_ctl, "exec Msg hello there\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg hello there\n").reply.status); try testing.expectEqualStrings("hello there", pane.msg[0..pane.msg_len]); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_ctl, 0, 4096).bytes); - - try testing.expectEqual(Status.ok, wr(p, root_ctl, "look /virtual/index\nexec New\n").reply.status); - var made: [MAX_PANES]u32 = undefined; - var n: usize = 0; - for (p.panes) |slot| if (slot) |q| if (q.serial != pane.serial) { - made[n] = q.serial; - n += 1; - }; - try testing.expectEqual(@as(usize, 2), n); - const listed = rd(p, root_ctl, 0, 4096); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n{d}\n", .{ @min(made[0], made[1]), @max(made[0], made[1]) }), listed.bytes); - const opened = p.panes[p.paneBySerial(@min(made[0], made[1])).?].?; + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{pane.serial}), rd(p, root_exec, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_look, "/virtual/index\n").reply.status); + const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); + + try testing.expectEqual(Status.ok, wr(p, root_exec, "New\n").reply.status); + const made = p.panes[p.active].?; + try testing.expect(made.serial != opened.serial); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{made.serial}), rd(p, root_exec, 0, 4096).bytes); } test "look opens spaced paths and locations without editing the source pane" { @@ -406,12 +347,12 @@ test "look opens spaced paths and locations without editing the source pane" { const p = try withFile(gpa, "source stays intact\n"); defer p.deinit(); const source = p.panes[0].?; - const ctl_node = Node.of(source.serial, .ctl); + const look_node = Node.of(source.serial, .look); const revision = source.file.?.revision; const undo_len = source.file.?.history.undo_len; source.cur_col = 3; - var command: [4200]u8 = undefined; - try testing.expectEqual(Status.ok, wr(p, ctl_node, try std.fmt.bufPrint(&command, "look {s}:2:3\n", .{path})).reply.status); + var clicked: [4200]u8 = undefined; + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&clicked, "{s}:2:3\n", .{path})).reply.status); const opened = p.panes[p.active].?; try testing.expect(opened != source); try testing.expectEqualStrings(path, opened.file.?.path); @@ -423,14 +364,14 @@ test "look opens spaced paths and locations without editing the source pane" { try testing.expectEqual(undo_len, source.file.?.history.undo_len); try testing.expectEqual(@as(i32, 3), source.cur_col); var expected: [16]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); p.active = 0; - try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&command, "look {s}:3:2\n", .{path})).reply.status); + try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&clicked, "{s}:3:2\n", .{path})).reply.status); try testing.expect(p.panes[p.active].? == opened); try testing.expectEqual(@as(i32, 2), opened.cur_row); try testing.expectEqual(@as(i32, 1), opened.cur_col); - try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_ctl, 0, 4096).bytes); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}\n", .{opened.serial}), rd(p, root_look, 0, 4096).bytes); } test "look validates the whole batch before opening virtual files" { @@ -438,17 +379,17 @@ test "look validates the whole batch before opening virtual files" { const p = try withFile(gpa, "source\n"); defer p.deinit(); const source = p.panes[0].?; - const ctl_node = Node.of(source.serial, .ctl); + const look_node = Node.of(source.serial, .look); const serial = p.next_serial; for ([_][]const u8{ - "look", "look \t", "look /virtual/index\x00ignored", "look /virtual/index\nbogus\n", + "/virtual/index\x00ignored", "/virtual/index\n/virtual/\x01index\n", }) |invalid| { - try testing.expectEqual(E.INVAL, wr(p, ctl_node, invalid).errno()); + try testing.expectEqual(E.INVAL, wr(p, look_node, invalid).errno()); try testing.expectEqual(serial, p.next_serial); try testing.expectEqual(@as(usize, 0), p.active); try testing.expectEqualStrings("source\n", source.file.?.content); } - try testing.expectEqual(Status.ok, wr(p, ctl_node, "look /n/self/index\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, look_node, "/n/self/index\n").reply.status); const opened = p.panes[p.active].?; try testing.expectEqualStrings("/virtual/index", opened.file.?.path); try testing.expect(std.mem.indexOf(u8, opened.file.?.content, "/test.txt") != null); @@ -480,7 +421,7 @@ test "get reloads the pane from disk and reports a missing file without losing d _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); _ = wr(p, Node.of(serial, .body), "unsaved\n"); - try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\nexec Del\n").errno()); + try testing.expectEqual(E.NOENT, wr(p, ctl_node, "get\n").errno()); try testing.expect(p.paneBySerial(serial) != null); try testing.expectEqualStrings("from disk\nunsaved\n", pane.file.?.content); try testing.expect(pane_files.dirtyOf(pane)); @@ -494,7 +435,7 @@ test "exec Save writes the named file through the host" { const pane = p.panes[0].?; try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .name), "/tmp/renamed.txt\n").reply.status); try testing.expect(pane_files.dirtyOf(pane)); - const saved = wr(p, Node.of(serial, .ctl), "exec Save\n"); + const saved = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saved.reply.status); try testing.expect(saved.saved); p.perform(.{ .save_file = .{ .pane = 0 } }); diff --git a/src/ninep/events.zig b/src/ninep/events.zig index fb49e41b..bd53bcd9 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -89,6 +89,13 @@ pub const Queue = struct { } }; +/// What the next read would answer, which is the size a stat reports: a +/// client can see there is something waiting without parking on it. +pub fn pending(q: *const Queue) u64 { + const record = q.peek() orelse return 0; + return record.len; +} + /// One record per read; `.again` parks the read until a record arrives. pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { const record = q.peek() orelse return .{ .tag = req.tag, .status = .again }; @@ -461,7 +468,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" const b = call(p, .{ .tag = 19, .op = .open, .node = event }); try testing.expectEqual(@as(u16, 2), p.fs.listeners); - _ = wr(p, Node.of(serial, .ctl), "exec Del\n"); + _ = wr(p, Node.of(serial, .exec), "Del\n"); try testing.expect(p.paneBySerial(serial) == null); try testing.expectEqual(@as(u16, 0), p.fs.listeners); @@ -471,7 +478,7 @@ test "a pane deleted while its event file is open leaves no suppression behind" try testing.expectEqual(E.NOENT, rd(p, event, 0, 64).errno()); try testing.expectEqual(E.NOENT, rd(p, Node.of(serial, .body), 0, 64).errno()); - try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .ctl), "clean\n").errno()); + try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .dirty), "0\n").errno()); try testing.expectEqual(E.NOENT, call(p, .{ .tag = 22, .op = .open, .node = event }).errno()); } @@ -535,7 +542,7 @@ test "the log parks until a pane is created, renamed, saved or deleted" { try std.fmt.bufPrint(&expected, "rename {d} /tmp/logged.txt\n", .{serial}), rd(p, log, 0, 4096).bytes, ); - const saving = wr(p, Node.of(serial, .ctl), "exec Save\n"); + const saving = wr(p, Node.of(serial, .exec), "Save\n"); try testing.expectEqual(Status.ok, saving.reply.status); try testing.expect(saving.saved); p.perform(.{ .save_file = .{ .pane = @intCast(id) } }); @@ -543,7 +550,7 @@ test "the log parks until a pane is created, renamed, saved or deleted" { try std.fmt.bufPrint(&expected, "save {d} /tmp/logged.txt\n", .{serial}), rd(p, log, 0, 4096).bytes, ); - try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "exec Del\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Del\n").reply.status); try testing.expectEqualStrings( try std.fmt.bufPrint(&expected, "del {d} /tmp/logged.txt\n", .{serial}), rd(p, log, 0, 4096).bytes, diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index b37ae645..42c0c48e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -1,5 +1,6 @@ -//! The files under /pane//: name, body, tag, ctl, addr, data, xdata, -//! sel, errors and event. Terminal panes add pty/ (pty.zig). +//! The files under /pane//: name, body, tag, ctl, the three range +//! files addr, dot and limit, data, xdata, sel, the three flag files dirty, +//! mark and scroll, errors, event, look and exec. Terminals add pty/ (pty.zig). const std = @import("std"); const pardes = @import("../pardes.zig"); const panes = @import("../panes.zig"); @@ -187,10 +188,15 @@ pub fn spliceBody(p: *Pardes, id: usize, pane: *Pane, q0: usize, q1: usize, byte return take; } -// ---- sizes reported by stat ---- +// ---- what stat reports ---- + +/// A range file's text: acme's addr format, two right-aligned offsets, so +/// that copying one range file onto another is the whole of `dot=addr`. +pub const range_len: u64 = 24; pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pane = p.panes[id] orelse return 0; + const pf = &p.fs.panes[id]; return switch (f) { .body, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, @@ -203,7 +209,24 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const d = dotOf(pane); break :sel @max(d.q0, d.q1) - d.q0; }, - .dir, .addr, .errors, .event, .pty, .pty_ctl, .pty_status, .pty_data => 0, + .addr, .dot => range_len, + .limit => if (pf.limit == null) 0 else range_len, + .dirty, .mark, .scroll => 2, + .look, .exec => ctl.resultsLen(p), + .event => events.pending(&pf.events), + .pty_status => pty.status_len, + .pty_data => events.pending(&pf.pty_out), + .dir, .errors, .pty, .pty_ctl => 0, + }; +} + +/// The qid version, which a client polls to watch a file for change. Only +/// the pane's text can promise one: its revision counts every edit, and +/// nothing else here changes in step with it. +pub fn versionOf(pane: *const Pane, f: PaneFile) u32 { + return switch (f) { + .body, .data, .xdata => if (pane.file) |*file| file.revision else 0, + else => 0, }; } @@ -246,13 +269,17 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .ctl => ctl.readPane(p, req, pane), .addr => addr: { clampAddr(pf, bodyOf(pane).len); - const out = p.fs.stage(p.gpa); - out.print(p.gpa, "{d:>11} {d:>11} ", .{ pf.addr.q0, pf.addr.q1 }) catch {}; - break :addr tree.stagedReply(p, req); + break :addr readRange(p, req, pf.addr); }, + .dot => readRange(p, req, dotOf(pane)), + .limit => readRange(p, req, pf.limit), .data => readData(req, id, pane, pf, false), .xdata => readData(req, id, pane, pf, true), .sel => readSel(req, id, pane), + .dirty => readFlag(p, req, dirtyOf(pane)), + .mark => readFlag(p, req, !pf.nomark), + .scroll => readFlag(p, req, !pf.noscroll), + .look, .exec => ctl.readResults(p, req), .event => events.readQueue(p, req, &pf.events), .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), @@ -260,6 +287,20 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply }; } +/// An unset limit reads empty, which is how a client tells it from one that +/// happens to bound nothing. +fn readRange(p: *Pardes, req: Req, r: ?State.Range) Reply { + const out = p.fs.stage(p.gpa); + if (r) |range| out.print(p.gpa, "{d:>11} {d:>11} ", .{ range.q0, range.q1 }) catch {}; + return tree.stagedReply(p, req); +} + +fn readFlag(p: *Pardes, req: Req, on: bool) Reply { + const out = p.fs.stage(p.gpa); + out.appendSlice(p.gpa, if (on) "1\n" else "0\n") catch {}; + return tree.stagedReply(p, req); +} + fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (pane.file != null) { const text = bodyOf(pane); @@ -322,10 +363,12 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .name => writeName(p, req, id, pane), .body => writeBody(p, req, id, pane), .tag => writeTag(p, req, pane), - .ctl => ctl.writePane(p, req, pane.serial), - .addr => writeAddr(p, req, id, pane), + .ctl => ctl.writePane(p, req, pane), + .addr, .dot, .limit => writeRange(p, req, id, pane, file), .data, .xdata => writeData(p, req, id, pane), .sel => writeSel(p, req, id, pane), + .dirty, .mark, .scroll => writeFlag(p, req, id, pane, file), + .look, .exec => ctl.command(p, req, pane.serial, file == .exec), .event => events.writeEvent(p, req, id), .errors => if (appendErrors(p, id, req.data)) |took| .{ .tag = req.tag, .written = @intCast(took) } @@ -389,15 +432,68 @@ fn writeSel(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return .{ .tag = req.tag, .written = @intCast(take) }; } -fn writeAddr(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { +/// The pair of offsets a range file reads back, so that copying `addr` onto +/// `dot` or `limit` does what acme spelled `dot=addr` and `limit=addr`. +fn pairOf(text: []const u8) ?State.Range { + var it = std.mem.tokenizeAny(u8, text, " \t"); + const q0 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; + const q1 = std.fmt.parseInt(u32, it.next() orelse return null, 10) catch return null; + if (it.next() != null) return null; + return .{ .q0 = q0, .q1 = @max(q0, q1) }; +} + +/// A range file takes an address expression, or that pair of offsets. +fn rangeOf(pf: *State, text: []const u8, data: []const u8) ?State.Range { + const expr = std.mem.trimEnd(u8, data, "\n"); + if (pairOf(expr)) |r| { + const n = clip(text.len); + return .{ .q0 = @min(r.q0, n), .q1 = @min(r.q1, n) }; + } + var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; + const r = a.address(pf.addr) orelse return null; + return if (a.i < expr.len) null else r; +} + +fn writeRange(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { const pf = &p.fs.panes[id]; const text = bodyOf(pane); clampAddr(pf, text.len); - const expr = std.mem.trimEnd(u8, req.data, "\n"); - var a: addressing.Addr = .{ .text = text, .lim = pf.limit, .expr = expr }; - const r = a.address(pf.addr) orelse return Reply.fail(req.tag, E.INVAL); - if (a.i < expr.len) return Reply.fail(req.tag, E.INVAL); - pf.addr = r; + const r = rangeOf(pf, text, req.data) orelse return Reply.fail(req.tag, E.INVAL); + switch (file) { + .addr => pf.addr = r, + .limit => pf.limit = r, + // Setting dot scrolls to it, which is the whole of acme's `show`. + .dot => { + if (fileOf(pane) == null) return Reply.fail(req.tag, E.INVAL); + setDot(pane, r); + }, + else => unreachable, + } + return .{ .tag = req.tag, .written = @intCast(req.data.len) }; +} + +fn flagOf(data: []const u8) ?bool { + const word = std.mem.trim(u8, data, " \t\r\n"); + if (std.mem.eql(u8, word, "1")) return true; + if (std.mem.eql(u8, word, "0")) return false; + return null; +} + +fn writeFlag(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply { + const on = flagOf(req.data) orelse return Reply.fail(req.tag, E.INVAL); + const pf = &p.fs.panes[id]; + switch (file) { + .dirty => { + const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL); + f.saved_revision = if (on) f.revision -% 1 else f.revision; + }, + .mark => { + pf.nomark = !on; + if (on) panes.File.pushUndo(p, pane); + }, + .scroll => pf.noscroll = !on, + else => unreachable, + } return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } @@ -475,14 +571,24 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { return text.len; } -/// Truncation empties a body; every other setattr just answers fresh attributes. +/// Truncation empties whatever the file holds: the body, the tag's editable +/// tail (acme's `cleartag`), a range. Every other setattr just answers fresh +/// attributes. pub fn truncate(p: *Pardes, id: usize, pane: *Pane, file: PaneFile) tree.Status { + const pf = &p.fs.panes[id]; switch (file) { .body, .data, .xdata => if (fileOf(pane) != null) { _ = spliceBody(p, id, pane, 0, bodyOf(pane).len, "") orelse return .err; - p.fs.panes[id].addr = .{}; + pf.addr = .{}; setDot(pane, .{}); }, + .tag => { + pane.tag_tail_len = 0; + pane.tag_init = true; + }, + .addr => pf.addr = .{}, + .limit => pf.limit = null, + .dot => if (fileOf(pane) != null) setDot(pane, .{}), else => {}, } return .ok; @@ -509,15 +615,19 @@ test "index lists serial kind dirty flag and name and stat reports its length" { const a = rd(p, index, 0, 4096); try testing.expectEqual(Status.ok, a.reply.status); var want: [64]u8 = undefined; - try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}), a.bytes); + const clean_row = try std.fmt.bufPrint(&want, "{d} text 0 /test.txt\n", .{pane.serial}); + try testing.expect(std.mem.indexOf(u8, a.bytes, clean_row) != null); + const at = std.mem.indexOf(u8, a.bytes, clean_row).?; const stat = call(p, .{ .tag = 1, .op = .getattr, .node = index }); try testing.expectEqual(@as(u64, a.bytes.len), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o444), stat.reply.attr.mode); pane.file.?.saved_revision = pane.file.?.revision -% 1; - try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d} text 1 /test.txt\n", .{pane.serial}), rd(p, index, 0, 4096).bytes); - const mid = rd(p, index, 2, 4); - try testing.expectEqualStrings("text", mid.bytes); + var dirty_want: [64]u8 = undefined; + const dirty_row = try std.fmt.bufPrint(&dirty_want, "{d} text 1 /test.txt\n", .{pane.serial}); + try testing.expect(std.mem.indexOf(u8, rd(p, index, 0, 4096).bytes, dirty_row) != null); + const kind = at + std.mem.indexOfScalar(u8, clean_row, ' ').? + 1; + try testing.expectEqualStrings("text", rd(p, index, kind, 4).bytes); const serial = try th.newPane(p); const both = rd(p, index, 0, 4096); @@ -533,10 +643,11 @@ test "a terminal is listed as term with its directory as name" { const pane = p.panes[0].?; p.setCwd(0, "/work/dir"); var want: [64]u8 = undefined; - try testing.expectEqualStrings( - try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}), + try testing.expect(std.mem.indexOf( + u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, - ); + try std.fmt.bufPrint(&want, "{d} term 0 /work/dir\n", .{pane.serial}), + ) != null); try testing.expectEqualStrings("/work/dir\n", rd(p, Node.of(pane.serial, .name), 0, 4096).bytes); try testing.expectEqual(E.PERM, wr(p, Node.of(pane.serial, .name), "/elsewhere\n").errno()); } @@ -566,6 +677,13 @@ test "body reads at any offset and writes append" { try testing.expectEqual(@as(u64, "one\ntwo\nthree\na".len), stat.reply.attr.size); try testing.expectEqual(@as(u16, 0o666), stat.reply.attr.mode); try testing.expect(stat.reply.attr.mtime >= p.fs.started); + + // The qid version is the pane's revision, so a stat sees an edit land. + try testing.expectEqual(p.panes[0].?.file.?.revision, stat.reply.attr.version); + _ = wr(p, body, "more\n"); + const later = call(p, .{ .tag = 7, .op = .getattr, .node = body }); + try testing.expect(later.reply.attr.version != stat.reply.attr.version); + try testing.expectEqual(@as(u32, 0), call(p, .{ .tag = 8, .op = .getattr, .node = Node.of(serial, .tag) }).reply.attr.version); } test "a body write to a terminal pane types at its shell" { @@ -653,18 +771,29 @@ test "sel reads the selection and writing sel replaces it" { const p = try withFile(gpa, "one\ntwo\n"); defer p.deinit(); const serial = serialOf(p); - const ctl_node = Node.of(serial, .ctl); + const addr_node = Node.of(serial, .addr); + const dot = Node.of(serial, .dot); const sel = Node.of(serial, .sel); - _ = wr(p, Node.of(serial, .addr), "#0,#3"); - try testing.expectEqual(Status.ok, wr(p, ctl_node, "dot=addr\n").reply.status); + // Copying addr onto dot is the whole of acme's `dot=addr`, and back again + // of `addr=dot`; each range file reads exactly what the other takes. + _ = wr(p, addr_node, "#0,#3"); + try testing.expectEqual(Status.ok, wr(p, dot, " 0 3 ").reply.status); + try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 100).bytes); + try testing.expectEqual(range_len, call(p, .{ .tag = 1, .op = .getattr, .node = dot }).reply.attr.size); try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes); try testing.expectEqual(@as(u64, 3), call(p, .{ .tag = 1, .op = .getattr, .node = sel }).reply.attr.size); - _ = wr(p, ctl_node, "addr=dot\n"); + _ = wr(p, addr_node, "#0"); + _ = wr(p, addr_node, " 0 3 "); try testing.expectEqual(@as(u32, 0), p.fs.panes[0].addr.q0); try testing.expectEqual(@as(u32, 3), p.fs.panes[0].addr.q1); + // A truncation empties dot; an address expression addresses it. + _ = call(p, .{ .tag = 9, .op = .setattr, .node = dot, .truncate = true }); + try testing.expectEqualStrings("", rd(p, sel, 0, 100).bytes); + try testing.expectEqual(Status.ok, wr(p, dot, "#0,#3").reply.status); + try testing.expectEqual(Status.ok, wr(p, sel, "ONE").reply.status); try testing.expectEqualStrings("ONE\ntwo\n", p.panes[0].?.file.?.content); _ = wr(p, sel, "!"); @@ -672,6 +801,62 @@ test "sel reads the selection and writing sel replaces it" { try testing.expectEqual(@as(u64, 0), call(p, .{ .tag = 2, .op = .getattr, .node = sel }).reply.attr.size); } +test "the flag files read and write the state acme kept behind ctl verbs" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one\ntwo\n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[0].?; + const pf = &p.fs.panes[0]; + const dirty = Node.of(serial, .dirty); + const mark = Node.of(serial, .mark); + const scroll = Node.of(serial, .scroll); + + for ([_]u64{ dirty, mark, scroll }) |node| { + try testing.expectEqual(@as(u64, 2), call(p, .{ .tag = 1, .op = .getattr, .node = node }).reply.attr.size); + for ([_][]const u8{ "", "yes", "2", "0 1", "true" }) |bad| + try testing.expectEqual(E.INVAL, wr(p, node, bad).errno()); + } + + try testing.expectEqualStrings("0\n", rd(p, dirty, 0, 8).bytes); + try testing.expectEqualStrings("1\n", rd(p, mark, 0, 8).bytes); + try testing.expectEqualStrings("1\n", rd(p, scroll, 0, 8).bytes); + + try testing.expectEqual(Status.ok, wr(p, dirty, "1\n").reply.status); + try testing.expect(dirtyOf(pane)); + try testing.expectEqualStrings("1\n", rd(p, dirty, 0, 8).bytes); + _ = wr(p, dirty, "0"); + try testing.expect(!dirtyOf(pane)); + + _ = wr(p, mark, "0"); + _ = wr(p, scroll, "0"); + try testing.expect(pf.nomark and pf.noscroll); + try testing.expectEqualStrings("0\n", rd(p, mark, 0, 8).bytes); + try testing.expectEqualStrings("0\n", rd(p, scroll, 0, 8).bytes); + _ = wr(p, mark, "1"); + _ = wr(p, scroll, "1"); + try testing.expect(!pf.nomark and !pf.noscroll); + + // A terminal has no file, so its dirty flag is readable but not writable. + const term = try th.withTerm(gpa); + defer term.deinit(); + try testing.expectEqualStrings("0\n", rd(term, Node.of(serialOf(term), .dirty), 0, 8).bytes); + try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno()); +} + +test "truncating the tag clears its editable tail" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const pane = p.panes[0].?; + const tag = Node.of(pane.serial, .tag); + _ = wr(p, tag, " Mine"); + try testing.expect(pane.tag_tail_len > 0); + const cleared = call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true }); + try testing.expectEqual(Status.ok, cleared.reply.status); + try testing.expectEqual(@as(usize, 0), pane.tag_tail_len); + try testing.expect(std.mem.indexOf(u8, rd(p, tag, 0, 4096).bytes, " Mine") == null); +} + test "name reads the file name and writing it promotes a scratch without touching its body" { const gpa = testing.allocator; const p = try withFile(gpa, "opener\n"); diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index e6696b85..1b71a8d3 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -76,6 +76,9 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool { return true; } +/// Three right-aligned fields: cols, rows and whether the host holds the tty. +pub const status_len: u64 = 36; + pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { const out = p.fs.stage(p.gpa); out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ @@ -146,7 +149,7 @@ test "a pane that is not a terminal has no pty/ at all" { var buf: [32]th.Dirent = undefined; const files = th.dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); + try testing.expectEqual(@as(usize, 17), files.len); try testing.expect(th.nameAt(files, "pty") == null); try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno()); @@ -167,7 +170,7 @@ test "a terminal pane's pty/ holds exactly ctl, status and data" { var buf: [32]th.Dirent = undefined; const files = th.dirents(rdir(p, dir, 0).bytes, &buf); - try testing.expectEqual(@as(usize, 11), files.len); + try testing.expectEqual(@as(usize, 18), files.len); try testing.expect(th.nameAt(files, "pty").?.dir); const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf); @@ -247,7 +250,7 @@ test "every pty/ctl verb, and every refusal" { "cooked", "winsize 80 24\nbogus", // a good verb beside a bad one "bogus\nwinsize 80 24", - "look x", // a `ctl` verb; the two files share no vocabulary + "look x", // a pane command; pty/ctl shares none of that vocabulary "exec Del", }) |bad| { const a = wr(p, ctl, bad); diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index 5b68b34c..2684aa41 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -83,22 +83,33 @@ pub fn releaseSnapshot(p: *Pardes, req: Req) bool { return true; } -pub fn readListeners(p: *Pardes, req: Req) Reply { - var buf: [512]u8 = undefined; - var text = std.Io.Writer.fixed(&buf); +const listeners_cap = 512; + +fn listenersText(p: *Pardes, buf: []u8) []const u8 { + var text = std.Io.Writer.fixed(buf); if (p.fs.socket_path.len != 0) - text.print("unix!{s}\n", .{p.fs.socket_path}) catch return Reply.fail(req.tag, E.IO); + text.print("unix!{s}\n", .{p.fs.socket_path}) catch return text.buffered(); for ([_]?std.Io.net.IpAddress{ p.fs.tcp_address, p.fs.quic_address }, [_][]const u8{ "tcp", "quic" }) |maybe, transport| { const address = maybe orelse continue; switch (address) { .ip4 => |ip| text.print("{s}!{d}.{d}.{d}.{d}!{d}\n", .{ transport, ip.bytes[0], ip.bytes[1], ip.bytes[2], ip.bytes[3], ip.port }) catch - return Reply.fail(req.tag, E.IO), + return text.buffered(), .ip6 => |ip| text.print("{s}!{f}!{d}\n", .{ transport, std.Io.net.Ip6Address.Unresolved{ .bytes = ip.bytes, .interface_name = null }, ip.port }) catch - return Reply.fail(req.tag, E.IO), + return text.buffered(), } } + return text.buffered(); +} + +pub fn listenersLen(p: *Pardes) u64 { + var buf: [listeners_cap]u8 = undefined; + return listenersText(p, &buf).len; +} + +pub fn readListeners(p: *Pardes, req: Req) Reply { + var buf: [listeners_cap]u8 = undefined; const out = p.fs.stage(p.gpa); - out.appendSlice(p.gpa, text.buffered()) catch return Reply.fail(req.tag, E.NOMEM); + out.appendSlice(p.gpa, listenersText(p, &buf)) catch return Reply.fail(req.tag, E.NOMEM); return tree.stagedReply(p, req); } diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig index ba26cb38..ad1d84e4 100644 --- a/src/ninep/testing.zig +++ b/src/ninep/testing.zig @@ -69,13 +69,30 @@ pub fn look_up(p: *Pardes, dir: u64, name: []const u8) Answer { return call(p, .{ .tag = 3, .op = .lookup, .node = dir, .data = name }); } -/// Opens /new once, as a client would, and returns the pane it made. +/// Creates a pane in /pane, as a client's mkdir does, and returns its serial. pub fn newPane(p: *Pardes) !u32 { - const new = @intFromEnum(tree.TopFile.new); - const opened = call(p, .{ .tag = 9, .op = .open, .node = new }); - if (opened.reply.status != .ok or opened.reply.handle == 0) return error.NoPane; - _ = call(p, .{ .tag = 9, .op = .release, .node = new, .handle = opened.reply.handle }); - return opened.reply.handle; + const made = mkdir(p, "scratch"); + if (made.reply.status != .ok) return error.NoPane; + const target = tree.Node.target(made.reply.attr.node) orelse return error.NoPane; + return switch (target) { + .pane => |t| t.serial, + .top => error.NoPane, + }; +} + +pub fn mkdir(p: *Pardes, name: []const u8) Answer { + return call(p, .{ + .tag = 9, + .op = .open, + .node = @intFromEnum(tree.TopFile.pane), + .data = name, + .create = true, + .perm = 0x8000_0000 | 0o755, + }); +} + +pub fn rmdir(p: *Pardes, node: u64) Answer { + return call(p, .{ .tag = 9, .op = .release, .node = node, .remove = true }); } pub fn withFile(gpa: std.mem.Allocator, text: []const u8) !*Pardes { diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 56c3112d..46609649 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -4,8 +4,10 @@ //! extended here with the editor's reply payload; fs.zig keeps host access //! and mounts. //! -//! /README /index /ctl /new /log /screen /listeners -//! /pane//{name,body,tag,ctl,addr,data,xdata,sel,errors,event,pty/} +//! /README /index /status /look /exec /log /screen /listeners +//! /pane/ mkdir makes a pane, rmdir closes it +//! /pane//{name,body,tag,ctl,addr,dot,limit,data,xdata,sel, +//! dirty,mark,scroll,errors,event,look,exec,pty/} //! /os/... the host filesystem /src/... embedded sources (opt-in) const std = @import("std"); const cloud9 = @import("cloud9"); @@ -47,11 +49,16 @@ pub const Payload = union(enum) { pub const Reply = cloud9.fs.ReplyWith(Payload); +/// Tcreate opens a pane and Tremove closes one; nothing else in the tree is +/// created or destroyed by the protocol, and wstat stays a truncation. +pub const features: cloud9.fs.Features = .{ .create = true, .remove = true }; + pub fn changesPane(req: Req) bool { return switch (req.op) { .write, .setattr => true, - .open => req.node == @intFromEnum(TopFile.new), - .lookup, .getattr, .read, .release, .readdir => false, + .open => req.create, + .release => req.remove, + .lookup, .getattr, .read, .readdir => false, }; } @@ -63,8 +70,9 @@ pub const TopFile = enum(u4) { root = 1, README, index, - ctl, - new, + status, + look, + exec, log, screen, listeners, @@ -77,8 +85,8 @@ pub const TopFile = enum(u4) { pub fn mode(f: TopFile) u16 { return switch (f) { .root, .pane => 0o755, - .ctl => 0o666, - .README, .index, .new, .log, .screen, .listeners => 0o444, + .look, .exec => 0o666, + .README, .index, .status, .log, .screen, .listeners => 0o444, }; } @@ -89,18 +97,26 @@ pub const TopFile = enum(u4) { pub const root: u64 = @intFromEnum(TopFile.root); -pub const PaneFile = enum(u4) { +/// Declaration order is readdir order. +pub const PaneFile = enum(u5) { dir = 0, name, body, tag, ctl, addr, + dot, + limit, data, xdata, sel, + dirty, + mark, + scroll, errors, event, + look, + exec, pty, pty_ctl, pty_status, @@ -138,8 +154,8 @@ pub const PaneFile = enum(u4) { }; pub const Node = packed struct(u64) { - file: u4 = 0, - serial: u60 = 0, + file: u5 = 0, + serial: u59 = 0, pub fn of(serial: u32, file: PaneFile) u64 { std.debug.assert(serial != 0); @@ -240,8 +256,13 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { // ---- dispatch ---- pub fn handle(p: *Pardes, req: Req) Reply { - if (req.node == fs.os_root or req.node & fs.os_node != 0) return fs.osHandle(p, req); - if (req.node & sources.archive_node != 0) return sources.handle(p, req); + const host = req.node == fs.os_root or req.node & fs.os_node != 0; + const archive = req.node & sources.archive_node != 0; + // Only /pane is created in and removed from. The host tree and the + // embedded sources say so, rather than quietly doing nothing. + if ((host or archive) and (req.create or req.remove)) return Reply.fail(req.tag, E.PERM); + if (host) return fs.osHandle(p, req); + if (archive) return sources.handle(p, req); const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); if (req.op == .write or req.op == .setattr) p.fs.origin = switch (target) { .pane => |t| @as(u8, if (t.file == .body or t.file == .tag) 'E' else 'F'), @@ -280,6 +301,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .mode = t.file.mode(), .size = pane.fileSize(p, id, t.file), .mtime = pane.mtimeOf(p, pn), + .version = pane.versionOf(pn, t.file), }; }, } @@ -292,13 +314,14 @@ fn attrReply(p: *Pardes, tag: u64, target: Target) Reply { fn topSize(p: *Pardes, f: TopFile) u64 { return switch (f) { - .root, .pane, .new, .log, .screen, .listeners => 0, + // ponytail: /screen has no length until an open renders its frame. + .root, .pane, .screen => 0, .index => pane.indexLen(p), .README => fs.help.len, - .ctl => ctl: { - var buf: [ctl.root_text_cap]u8 = undefined; - break :ctl ctl.rootText(p, &buf).len; - }, + .status => ctl.statusLen(p), + .look, .exec => ctl.resultsLen(p), + .log => events.pending(&p.fs.log), + .listeners => screen.listenersLen(p), }; } @@ -366,7 +389,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| switch (f) { .root => { - inline for (.{ TopFile.README, TopFile.index, TopFile.ctl, TopFile.new, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { + inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.pane }) |t| { if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, @intFromEnum(t), t.dir(), t.fileName()); } if (skip > 0) skip -= 1 else stageDirent(out, p.gpa, fs.os_root, true, "os"); @@ -403,21 +426,27 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .payload = .{ .staged = @intCast(out.items.len) } }; } -fn newPane(p: *Pardes) ?u32 { - const slot = p.freeSlot() orelse return null; +/// Tcreate in /pane opens a pane, the way mkdir opens a directory. The name +/// asked for is ignored: a pane is named by the serial the editor gives it, +/// which the reply carries back and /index lists last. +fn create(p: *Pardes, req: Req, target: Target) Reply { + switch (target) { + .top => |f| if (f != .pane) return Reply.fail(req.tag, E.PERM), + .pane => return Reply.fail(req.tag, E.PERM), + } + if (req.perm & cloud9.dmdir == 0) return Reply.fail(req.tag, E.PERM); + const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); p.newScratchBelow(p.active); - const pn = p.panes[slot] orelse return null; - return pn.serial; + const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); + const attr = attrOf(p, .{ .pane = .{ .serial = made.serial, .file = .dir } }) orelse + return Reply.fail(req.tag, E.NFILE); + return .{ .tag = req.tag, .handle = 1, .attr = attr }; } fn open(p: *Pardes, req: Req, target: Target) Reply { + if (req.create) return create(p, req, target); switch (target) { .top => |f| switch (f) { - // The clone idiom: each open makes one empty pane; reads answer its serial. - .new => { - const serial = newPane(p) orelse return Reply.fail(req.tag, E.NFILE); - return .{ .tag = req.tag, .handle = serial }; - }, .screen => return screen.openSnapshot(p, req, true), .log => p.fs.log_readers +|= 1, else => {}, @@ -429,11 +458,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT); switch (t.file) { .body => if (pn.isTerminal()) return screen.openSnapshot(p, req, false), - .ctl => pf.limit = null, - .addr => { - pf.addr = .{}; - pf.limit = null; - }, + .addr => pf.addr = .{}, .event => { pf.readers +|= 1; p.fs.listeners +|= 1; @@ -446,7 +471,27 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { return .{ .tag = req.tag, .handle = 1 }; } +/// Tremove closes a pane. Nothing else in the tree can be removed, and this +/// is also where an ORCLOSE open lands, so every node is checked here. +fn remove(p: *Pardes, req: Req) Reply { + const target = Node.target(req.node) orelse return Reply.fail(req.tag, E.NOENT); + const t = switch (target) { + .pane => |t| t, + .top => return Reply.fail(req.tag, E.PERM), + }; + if (t.file != .dir) return Reply.fail(req.tag, E.PERM); + const id = p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT); + p.removePane(id) catch return Reply.fail(req.tag, E.IO); + return .{ .tag = req.tag }; +} + fn release(p: *Pardes, req: Req) Reply { + // The handle's bookkeeping runs whether or not the removal is allowed. + const done = releaseHandle(p, req); + return if (req.remove) remove(p, req) else done; +} + +fn releaseHandle(p: *Pardes, req: Req) Reply { if (screen.releaseSnapshot(p, req)) return .{ .tag = req.tag }; const target = Node.target(req.node) orelse return .{ .tag = req.tag }; switch (target) { @@ -502,12 +547,8 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { } break :index stagedReply(p, req); }, - .ctl => ctl.readRoot(p, req), - .new => new: { - if (req.handle == 0) break :new Reply.fail(req.tag, E.INVAL); - p.fs.stage(p.gpa).print(p.gpa, "{d}\n", .{req.handle}) catch break :new Reply.fail(req.tag, E.NOMEM); - break :new stagedReply(p, req); - }, + .status => ctl.readStatus(p, req), + .look, .exec => ctl.readResults(p, req), .log => events.readQueue(p, req, &p.fs.log), .screen => screen.readSnapshot(p, req, null), .listeners => screen.readListeners(p, req), @@ -525,7 +566,7 @@ fn read(p: *Pardes, req: Req, target: Target) Reply { fn write(p: *Pardes, req: Req, target: Target) Reply { switch (target) { .top => |f| return switch (f) { - .ctl => ctl.writeRoot(p, req), + .look, .exec => ctl.command(p, req, null, f == .exec), else => Reply.fail(req.tag, E.PERM), }, .pane => |t| { @@ -587,8 +628,8 @@ test "filesystem inspection preserves pending and displayed Look hover" { const preview = p.look_hover_preview; const requests = [_]Req{ .{ .tag = 1, .op = .lookup, .node = root, .data = "screen" }, - .{ .tag = 1, .op = .lookup, .node = root, .data = "new" }, - .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 1, .op = .lookup, .node = root, .data = "status" }, + .{ .tag = 1, .op = .getattr, .node = @intFromEnum(TopFile.pane) }, .{ .tag = 2, .op = .getattr, .node = body }, .{ .tag = 3, .op = .open, .node = body }, .{ .tag = 4, .op = .read, .node = body, .size = 5 }, @@ -614,7 +655,7 @@ test "filesystem inspection preserves pending and displayed Look hover" { test "filesystem pane creation and truncation cancel Look hover" { const requests = [_]Req{ - .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.new) }, + .{ .tag = 2, .op = .open, .node = @intFromEnum(TopFile.pane), .data = "x", .create = true, .perm = cloud9.dmdir | 0o755 }, .{ .tag = 3, .op = .setattr, .node = 0, .truncate = true }, }; for (requests) |request| { @@ -644,13 +685,13 @@ test "readdir lists the root and a pane directory without creating anything" { var top_bytes: [4096]u8 = undefined; @memcpy(top_bytes[0..top_reply.bytes.len], top_reply.bytes); const top = th.dirents(top_bytes[0..top_reply.bytes.len], &buf); - try testing.expect(top.len >= 9); - for ([_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { + try testing.expect(top.len >= 10); + for ([_][]const u8{ "README", "index", "status", "look", "exec", "log", "screen", "listeners", "pane", "os" }, 0..) |name, i| { try testing.expectEqualStrings(name, top[i].name); - try testing.expectEqual(i >= 7, top[i].dir); + try testing.expectEqual(i >= 8, top[i].dir); } - try testing.expectEqual(@intFromEnum(TopFile.pane), top[7].node); - try testing.expectEqual(fs.os_root, top[8].node); + try testing.expectEqual(@intFromEnum(TopFile.pane), top[8].node); + try testing.expectEqual(fs.os_root, top[9].node); var idbuf: [16]u8 = undefined; try testing.expect(th.nameAt(top, try std.fmt.bufPrint(&idbuf, "{d}", .{serial})) == null); try testing.expect(th.nameAt(top, "self") == null); @@ -674,13 +715,18 @@ test "readdir lists the root and a pane directory without creating anything" { } const panes_dir = rdir(p, @intFromEnum(TopFile.pane), 0); const listed = th.dirents(panes_dir.bytes, &buf); - try testing.expectEqual(@as(usize, 1), listed.len); - try testing.expectEqual(Node.of(serial, .dir), listed[0].node); + var idname: [16]u8 = undefined; + const own = th.nameAt(listed, try std.fmt.bufPrint(&idname, "{d}", .{serial})).?; + try testing.expectEqual(Node.of(serial, .dir), own.node); + try testing.expect(own.dir); const dir = rdir(p, Node.of(serial, .dir), 0); const files = th.dirents(dir.bytes, &buf); - try testing.expectEqual(@as(usize, 10), files.len); - for ([_][]const u8{ "name", "body", "tag", "ctl", "addr", "data", "xdata", "sel", "errors", "event" }, 0..) |name, i| + try testing.expectEqual(@as(usize, 17), files.len); + for ([_][]const u8{ + "name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata", + "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", + }, 0..) |name, i| try testing.expectEqualStrings(name, files[i].name); try testing.expect(th.nameAt(files, ".") == null); try testing.expect(th.nameAt(files, "rdsel") == null); @@ -703,8 +749,10 @@ test "lookup resolves top files, pane serials and pane files" { const serial = serialOf(p); try testing.expectEqual(@as(u64, @intFromEnum(TopFile.index)), look_up(p, root, "index").reply.attr.node); - try testing.expect(!look_up(p, root, "new").reply.attr.dir); - try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "new").reply.attr.mode); + try testing.expect(!look_up(p, root, "status").reply.attr.dir); + try testing.expectEqual(@as(u16, 0o444), look_up(p, root, "status").reply.attr.mode); + try testing.expectEqual(@as(u16, 0o666), look_up(p, root, "look").reply.attr.mode); + try testing.expectEqual(E.NOENT, look_up(p, root, "new").errno()); try testing.expect(look_up(p, root, "pane").reply.attr.dir); try testing.expect(look_up(p, root, "os").reply.attr.dir); try testing.expectEqual(E.NOENT, look_up(p, root, "nosuchthing").errno()); @@ -732,38 +780,46 @@ test "lookup resolves top files, pane serials and pane files" { try testing.expectEqual(E.NOTDIR, look_up(p, Node.of(serial, .body), "x").errno()); } -test "each open of new creates exactly one pane whose serial the read answers" { +test "creating in the pane directory opens a pane and removing one closes it" { const gpa = testing.allocator; const p = try withFile(gpa, "first\n"); defer p.deinit(); const before = p.next_serial; - const new = @intFromEnum(TopFile.new); + const panes_dir = @intFromEnum(TopFile.pane); - try testing.expectEqual(Status.ok, look_up(p, root, "new").reply.status); - try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .getattr, .node = new }).reply.status); + // Browsing /pane creates nothing; only a create does. + try testing.expectEqual(Status.ok, rdir(p, panes_dir, 0).reply.status); try testing.expectEqual(before, p.next_serial); - const opened = call(p, .{ .tag = 1, .op = .open, .node = new }); - try testing.expectEqual(Status.ok, opened.reply.status); + const made = th.mkdir(p, "scratch"); + try testing.expectEqual(Status.ok, made.reply.status); try testing.expectEqual(before + 1, p.next_serial); - try testing.expectEqual(before + 1, opened.reply.handle); - try testing.expectEqual(@as(u64, 0), opened.reply.attr.node); + const serial = before + 1; + try testing.expectEqual(Node.of(serial, .dir), made.reply.attr.node); + try testing.expect(made.reply.attr.dir); var expected: [16]u8 = undefined; - const line = try std.fmt.bufPrint(&expected, "{d}\n", .{opened.reply.handle}); - try testing.expectEqualStrings(line, call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .size = 64 }).bytes); - try testing.expectEqualStrings(line[1..], call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = 1, .size = 64 }).bytes); - try testing.expectEqualStrings("", call(p, .{ .tag = 2, .op = .read, .node = new, .handle = opened.reply.handle, .off = line.len, .size = 64 }).bytes); - try testing.expectEqual(before + 1, p.next_serial); - _ = call(p, .{ .tag = 3, .op = .release, .node = new, .handle = opened.reply.handle }); + try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "{d}", .{serial}), made.reply.attr.name); - const id = p.paneBySerial(opened.reply.handle).?; + const id = p.paneBySerial(serial).?; try testing.expectEqualStrings("", p.panes[id].?.file.?.content); - _ = wr(p, Node.of(opened.reply.handle, .body), "hi"); - const second = call(p, .{ .tag = 4, .op = .open, .node = new }); + _ = wr(p, Node.of(serial, .body), "hi"); + const second = th.mkdir(p, "another"); try testing.expectEqual(Status.ok, second.reply.status); - try testing.expect(second.reply.handle != opened.reply.handle); + try testing.expect(second.reply.attr.node != made.reply.attr.node); try testing.expectEqualStrings("hi", p.panes[id].?.file.?.content); - try testing.expectEqual(E.INVAL, call(p, .{ .tag = 5, .op = .read, .node = new, .size = 64 }).errno()); + + // A plain file, and a create anywhere else, are refused. + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = panes_dir, .data = "f", .create = true, .perm = 0o666 }).errno()); + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = root, .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + try testing.expectEqual(E.PERM, call(p, .{ .tag = 1, .op = .open, .node = Node.of(serial, .dir), .data = "d", .create = true, .perm = cloud9.dmdir | 0o755 }).errno()); + + // Tremove closes the pane it names, and nothing else in the tree. + try testing.expectEqual(E.PERM, th.rmdir(p, Node.of(serial, .body)).errno()); + try testing.expectEqual(E.PERM, th.rmdir(p, @intFromEnum(TopFile.index)).errno()); + try testing.expect(p.paneBySerial(serial) != null); + try testing.expectEqual(Status.ok, th.rmdir(p, Node.of(serial, .dir)).reply.status); + try testing.expect(p.paneBySerial(serial) == null); + try testing.expectEqual(E.NOENT, th.rmdir(p, Node.of(serial, .dir)).errno()); } test "directories walk to their parents" { @@ -791,14 +847,15 @@ test "editor paths resolve to the same nodes the wire serves" { try testing.expectEqual(root, resolveSelf(p, "").?); try testing.expectEqual(root, resolveSelf(p, "/./pane/../").?); try testing.expectEqual(@intFromEnum(TopFile.index), resolveSelf(p, "index").?); - try testing.expectEqual(@intFromEnum(TopFile.new), resolveSelf(p, "new").?); + try testing.expectEqual(@intFromEnum(TopFile.status), resolveSelf(p, "status").?); + try testing.expect(resolveSelf(p, "new") == null); try testing.expectEqual(@intFromEnum(TopFile.pane), resolveSelf(p, "pane").?); try testing.expectEqual(Node.of(serial, .dir), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}", .{serial})).?); try testing.expectEqual(Node.of(serial, .sel), resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/sel", .{serial})).?); try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/pty/ctl", .{serial})) == null); try testing.expect(resolveSelf(p, try std.fmt.bufPrint(&path, "pane/{d}/rdsel", .{serial})) == null); try testing.expect(resolveSelf(p, "self/index") == null); - try testing.expect(resolveSelf(p, "new/ctl") == null); + try testing.expect(resolveSelf(p, "status/ctl") == null); try testing.expect(resolveSelf(p, "cons") == null); try testing.expect(resolveSelf(p, "pane/0") == null); } diff --git a/src/panes.zig b/src/panes.zig index edd7038f..9a6802d0 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -6273,6 +6273,13 @@ pub const Terminal = struct { state.vt.screens.active.scroll(.{ .delta_row = delta }); } + /// Is the viewport already showing the last row of output? + pub fn atBottom(pane: *const Pane) bool { + if (comptime !enabled) return true; + const sb = scrollbar(pane); + return sb.offset + sb.len >= sb.total; + } + /// Snap the viewport back onto live output. pub fn followOutput(pane: *Pane) void { if (comptime !enabled) return; @@ -6559,10 +6566,14 @@ pub const Terminal = struct { const has_positions = pane.ovl != null or pane.cur_pinned or pane.vsel.active or pane.msel.active or pane.nsel > 0 or pane.append_at != null or pane.look_at != null or pane.ed_undo_len > 0 or pane.ed_redo_len > 0 or pointerRowCount(pane) > 0; + // Sticky bottom, as every other terminal does it: output drags the + // viewport down only when the viewport was already on the last row, so + // a reader scrolled back into the scrollback stays where they scrolled. + const at_bottom = atBottom(pane); if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); p.shell_rows.markStale(pane); const body_mode = if (pane.tag_edit) pane.tag_mode else pane.mode; - if (body_mode == .tty) followOutput(pane); + if (body_mode == .tty and at_bottom) followOutput(pane); } const RowPin = struct { pin: ?*ghostty_vt.Pin = null, offset: i32 = 0 }; @@ -6890,6 +6901,7 @@ pub const Terminal = struct { }; pane.mode = .tty; + followOutput(pane); // entering raw mode is a request for the live shell pane.msel.active = false; pane.vsel.active = false; pane.nsel = 0; diff --git a/src/pardes.zig b/src/pardes.zig index ed71102e..c66688f5 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -295,6 +295,12 @@ const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; +// ...and the defaults from before the closing word moved to the end of every +// tagline, where a click cannot land on it by overshooting the one before. +const pre_close_last_pane_tail = " Tty Collapse Del"; +const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; +const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; +const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; const Builtin = builtins.registry.Builtin(); @@ -1078,7 +1084,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" const height = p.rects[0].h; const revision = pane.file.?.revision; for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| - try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse")); + try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); @@ -1115,6 +1121,8 @@ test "LocationsConfig command reports partial updates and survives restore" { try std.testing.expectEqual(expected, p.locations_config); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on")); try std.testing.expectEqual(expected, p.locations_config); + // Setting it is silent; asking bare is what opens the window. + try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig")); var reports: usize = 0; for (p.panes) |slot| { const pane = slot orelse continue; @@ -1409,7 +1417,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); - try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), @@ -1430,7 +1438,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -1576,7 +1584,7 @@ test "pane defaults keep creation of scratch panes in column tags" { var words = std.mem.tokenizeScalar(u8, tag, ' '); while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); } - try std.testing.expectEqualStrings("New Tty Find Grep Joincol", p.columnTag(0)); + try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0)); const terminal = p.panes[0].?; p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(!terminal.tag_init); @@ -2441,13 +2449,13 @@ test "Save reaches every tagline with text behind it and no other" { }); p.restoreTail(out, scratch_dumped); try std.testing.expect(!out.tag_init); - try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -5818,8 +5826,42 @@ test "repeated non-dyadic column splits preserve every unrelated boundary" { pub const Rect = layout.Rect; +/// The session layouts `init` can put on screen. One name each, so choosing +/// between them reads as a choice instead of a chain of ifs over four options. +pub const Boot = enum { + /// the document named on the command line, or the pane explaining why not + document, + /// a Look that found nothing: reported, not opened + missing, + /// 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 + /// pane under it so the editor half is there before you want it + tty, + /// that raw shell by itself, which is all a core booted for a test wants + tty_shell, + /// acme's boot: two columns, the left one split in two + classic, + /// one greeted shell + shell, + + pub fn of(opts: Options) Boot { + if (opts.boot) |named| return named; + if (opts.file != null) return .document; + if (opts.missing != null) return .missing; + 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 + return if (opts.shells >= 3) .classic else .shell; + } +}; + pub const Options = struct { tty_only: bool = false, + /// The layout to boot, when a host means one outright rather than leaving + /// it to be read off the options below. `--tty` names `.tty`; a core + /// booted for a test names nothing and gets `.tty_shell`. + boot: ?Boot = null, /// initial shell panes: 1 (default) or 3 for the classic two-column boot. /// A `file` outranks this — see there. shells: u8 = 1, @@ -6035,6 +6077,11 @@ pub const Pardes = struct { pipe_wait: ?PendingPipe = null, fs: filesystem.Namespace = .{}, + boot: Boot = .shell, + /// Builtins announce themselves on the message row. Off while startup + /// configuration is applied: those lines are nobody clicking, and should + /// not leave the row holding the name of the last one. + announce: bool = true, // Reject overflow: evicting an older effect would reorder a byte stream. effects: [limits.effect_cap]Effect = undefined, @@ -6114,7 +6161,10 @@ pub const Pardes = struct { for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; p.opts.ninep_identity = .{}; - if (opts.file) |path| { + p.boot = Boot.of(opts); + switch (p.boot) { + .document => { + const path = opts.file.?; const opened = initial_doc: { if (comptime pdf_enabled) if (look.isPdfPath(path)) break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line); @@ -6137,10 +6187,10 @@ pub const Pardes = struct { errdefer gpa.free(content); _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content); } - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (opts.missing) |missing| { + p.oneColumn(); + }, + .missing => { + const missing = opts.missing.?; const content = try std.fmt.allocPrint( gpa, "file or directory not found\n\n\t{s}\n", @@ -6148,24 +6198,26 @@ pub const Pardes = struct { ); errdefer gpa.free(content); _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content); - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (comptime platform == .esp32p4) { + p.oneColumn(); + }, + .embedded => { const content = try p.gpa.dupe(u8, boot_buffer); errdefer p.gpa.free(content); _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content); - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (opts.tty_only) { + p.oneColumn(); + }, + .tty, .tty_shell => { _ = try p.newShell(0, ""); p.panes[0].?.mode = .tty; - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; - } else if (opts.shells >= 3) { - // classic layout: two columns, the left one split in two. + p.oneColumn(); + // A bare tty is still an editor: it opens with somewhere to put + // text under the shell. The shell keeps the keyboard. + if (p.boot == .tty) { + p.newScratchBelow(0); + p.active = 0; + } + }, + .classic => { _ = try p.newShell(0, ""); _ = try p.newShell(1, ""); _ = try p.newShell(2, ""); @@ -6176,13 +6228,12 @@ pub const Pardes = struct { p.col_panes[0][1] = 1; p.col_n[1] = 1; p.col_panes[1][0] = 2; - } else { - // ponytail: 1 and 3 are the only boot layouts; anything else acts as 1 + }, + .shell => { _ = try p.newShell(0, ""); p.panes[0].?.greet = true; - p.ncol = 1; - p.col_n[0] = 1; - p.col_panes[0][0] = 0; + p.oneColumn(); + }, } p.sync(); p.applyStartupConfig(); @@ -6195,6 +6246,13 @@ pub const Pardes = struct { return p; } + /// The layout every single-pane boot starts from. + fn oneColumn(p: *Pardes) void { + p.ncol = 1; + p.col_n[0] = 1; + p.col_panes[0][0] = 0; + } + pub fn deinit(p: *Pardes) void { p.cancelLookHover(); for (0..MAX_PANES) |id| p.dropPendingWrite(id); @@ -6268,7 +6326,7 @@ pub const Pardes = struct { } pub fn columnTag(p: *const Pardes, c: usize) []const u8 { - return p.col_tags[c].slice("New Tty Find Grep Joincol"); + return p.col_tags[c].slice("New Tty Find Grep Joincol Delcol"); } fn headerText(p: *Pardes) []const u8 { @@ -6413,6 +6471,17 @@ pub const Pardes = struct { return np; } + /// The empty pane that stands in for one that closed, keeping its column + /// alive and its directory. Unlike `newScratch` it inherits nothing: the + /// pane it replaces is on its way out. + fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { + const content = try p.gpa.dupe(u8, ""); + errdefer p.gpa.free(content); + const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); + np.cur_pinned = true; + return np; + } + /// New: a scratch below the calling pane, in its column. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { const free = p.freeSlot() orelse return; @@ -8237,8 +8306,23 @@ pub const Pardes = struct { p.ctrl_w_pending = false; if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle) return p.toggleTty(p.active); + // Shift-Escape is the unconditional way out, even mid-application; + // bare Escape only leaves at a shell prompt. + if (hit(key, config.tty_toggle_alt)) + return p.runBuiltin(.Last, p.active, "", null); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and p.takesCommandLine(p.active)) return p.runBuiltin(.Last, p.active, "", null); + panes.Terminal.followOutput(pane); // typing snaps back to live output + // Paste stays the window's, the one exception to forwarding a raw + // tty's keys: Ctrl-V types the register at the program and + // Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded, + // they reach an application that reads the clipboard on its own + // account instead — a coding agent goes there hunting for an image. + // A host that folds Shift into the letter says the same thing. + if (key.ctrl and !key.alt and (key.cp == 'v' or key.cp == 'V')) { + if (key.shift or key.cp == 'V') return p.clipRequest(p.active, .after); + return p.typeToTty(p.active, pane, p.yank orelse return); + } return panes.Terminal.forwardKey(p, p.active, key); } if (p.leader_on) return p.leaderKey(key); @@ -12499,6 +12583,8 @@ pub const Pardes = struct { } fn applyStartupConfig(p: *Pardes) void { + p.announce = false; + defer p.announce = true; var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); while (lines.next()) |line| _ = p.executeBuiltinLine(p.active, line); } @@ -12506,6 +12592,11 @@ pub const Pardes = struct { fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse 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. + if (p.announce and p.settings.verbose and builtins.registry.announces(b)) + p.setMessage(id, @tagName(b)); const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } @@ -12887,11 +12978,13 @@ pub const Pardes = struct { const rest = saved_tag[pfx.len..]; const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { - .generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, - .file => &.{ context_pane_tail, file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, + .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .file => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, .output => &.{ locations_pane_tail, file_pane_tail, + pre_close_last_file_pane_tail, + pre_close_last_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, pane_tail, @@ -12902,7 +12995,7 @@ pub const Pardes = struct { pre_collapse_file_pane_tail, pre_collapse_pane_tail, }, - .terminal => &.{ terminal_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .terminal => &.{ terminal_pane_tail, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; @@ -12912,11 +13005,34 @@ pub const Pardes = struct { pub fn removePane(p: *Pardes, id: usize) !void { const pane = p.panes[id] orelse return; + // A column outlives its panes. Closing the last one leaves an empty + // pane standing in its place and in its directory; only Delcol ever + // takes a column away. + const place = layout.findPane(p, id); + const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; + var dir_buf: [limits.host_path_cap]u8 = undefined; + var dir: []const u8 = ""; + if (last_in_column) { + const had = paneDir(pane); + const n = @min(had.len, dir_buf.len); + @memcpy(dir_buf[0..n], had[0..n]); + dir = dir_buf[0..n]; + } + // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); + // ...and after it, so the stand-in inherits none of the closing pane. + const heir: ?usize = heir: { + if (!last_in_column) break :heir null; + const f = place.?; + const free = p.freeSlot() orelse break :heir null; + _ = p.newScratchIn(free, dir) catch break :heir null; + layout.insert(p, f.col, f.idx + 1, free); + break :heir free; + }; layout.absorbVWeight(p, id); layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = p.prevFocus(id) orelse { + if (p.active == id) p.active = heir orelse p.prevFocus(id) orelse { p.quit = true; p.emit(.quit); return; @@ -14408,6 +14524,75 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } +test "the bare tty startup opens a text pane under the shell, unfocused" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .boot = .tty }); + defer p.deinit(); + try std.testing.expectEqual(Boot.tty, p.boot); + + // One column, shell on top in raw mode, an empty text pane under it. + try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); + const shell = p.panes[p.col_panes[0][0]].?; + const scratch = p.panes[p.col_panes[0][1]].?; + try std.testing.expect(shell.isTerminal()); + try std.testing.expectEqual(Pane.Mode.tty, shell.mode); + try std.testing.expect(!scratch.isTerminal()); + try std.testing.expectEqualStrings("", scratch.file.?.content); + + // ...and the shell keeps the keyboard. + try std.testing.expectEqual(p.col_panes[0][0], p.active); + + // Unnamed, it is the bare shell every test asks for. + const plain = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer plain.deinit(); + try std.testing.expectEqual(Boot.tty_shell, plain.boot); + try std.testing.expectEqual(@as(usize, 1), plain.col_n[0]); +} + +test "tty output follows only from the bottom" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); + p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc + const shell = p.active; + const sp = p.panes[shell].?; + p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); + try std.testing.expectEqual(Pane.Mode.tty, sp.mode); + + for (0..60) |i| { + var buf: [32]u8 = undefined; + p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); + } + p.sync(); + const bottom = panes.Terminal.scrollbar(sp).offset; + try std.testing.expect(bottom > 0); + + // Scrolled back, the reader stays put however much the shell prints. + sp.scrollBy(-10); + p.sync(); + const parked = panes.Terminal.scrollbar(sp).offset; + try std.testing.expect(parked < bottom); + for (60..70) |i| { + var buf: [32]u8 = undefined; + p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); + } + p.sync(); + try std.testing.expectEqual(parked, panes.Terminal.scrollbar(sp).offset); + + // Typing snaps back to live output, so nobody types blind. + p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); + p.sync(); + try std.testing.expect(panes.Terminal.scrollbar(sp).offset > parked); + + // ...and from there output follows again. + const live = panes.Terminal.scrollbar(sp).offset; + p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } }); + p.sync(); + try std.testing.expect(panes.Terminal.scrollbar(sp).offset > live); +} + test "Esc back into a file leaves its view where it was" { if (platform == .web) return; const gpa = std.testing.allocator; @@ -14461,15 +14646,23 @@ test "raw tty Escape only leaves at a shell prompt" { const pane = p.panes[shell].?; var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); - for ([_]Key{ - .{ .cp = Key.escape, .shift = true }, - }) |key| { - p.update(.{ .key = key }); - try std.testing.expectEqual(shell, p.active); - try std.testing.expectEqual(Pane.Mode.tty, pane.mode); - try std.testing.expect(drainWrites(p, &buf).len > 0); - } - p.update(.{ .key = .{ .cp = Key.escape } }); + + // Ordinary keys reach the child; Shift-Escape is the one unconditional way + // out and sends it nothing. + p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); + try std.testing.expectEqual(shell, p.active); + try std.testing.expect(drainWrites(p, &buf).len > 0); + + p.update(.{ .key = .{ .cp = Key.escape, .shift = true } }); + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + + p.update(.{ .key = .{ .cp = Key.escape } }); // back into the shell + try std.testing.expectEqual(shell, p.active); + _ = drainWrites(p, &buf); + + p.update(.{ .key = .{ .cp = Key.escape } }); // bare Escape leaves at a prompt try std.testing.expectEqual(@as(usize, 0), p.active); try std.testing.expectEqualStrings("", drainWrites(p, &buf)); try std.testing.expectEqual(Pane.Mode.tty, pane.mode); @@ -14620,9 +14813,10 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" { defer p.deinit(); var buf: [256]u8 = undefined; _ = drainWrites(p, &buf); - p.setYank("must not be pasted"); + p.setYank("register text"); for ('a'..'z' + 1) |letter| { if (letter == p.opts.tty_toggle) continue; // editor/TTY mode shortcut + if (letter == 'v') continue; // the window's paste, asserted just below p.update(.{ .key = .{ .cp = @intCast(letter), .ctrl = true } }); const expected = [_]u8{@intCast(letter - 'a' + 1)}; // The terminal encoder distinguishes Ctrl-I/M from Tab/Return. @@ -14636,15 +14830,21 @@ test "raw tty forwards Ctrl letters and former global shortcuts to the child" { try std.testing.expectEqual(Pane.Mode.tty, p.panes[0].?.mode); try std.testing.expect(!p.ctrl_w_pending and p.clip_pending == null); } - p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); - try std.testing.expectEqualStrings("\x1b[118;6u", drainWrites(p, &buf)); + // ...and the two paste chords are the exception: Ctrl-V types the register + // at the program, Ctrl-Shift-V asks the desktop first and types nothing yet. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings("register text", drainWrites(p, &buf)); try std.testing.expect(p.clip_pending == null); + p.update(.{ .key = .{ .cp = 'v', .ctrl = true, .shift = true } }); + try std.testing.expectEqualStrings("", drainWrites(p, &buf)); + try std.testing.expect(p.clip_pending != null); + const unopened = p.freeSlot(); // whatever the boot layout already used for ([_]u8{ 'n', 'c' }) |letter| { p.update(.{ .key = .{ .cp = letter, .alt = true } }); const expected = [_]u8{ 0x1b, letter }; try std.testing.expectEqualStrings(&expected, drainWrites(p, &buf)); try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expect(p.panes[1] == null); + try std.testing.expectEqual(unopened, p.freeSlot()); } p.update(.{ .key = .{ .cp = Key.up } }); try std.testing.expectEqualStrings("\x1b[A", drainWrites(p, &buf)); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 23b7c09b..a1917ba9 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -71,6 +71,21 @@ fn inputReader(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) void { loop.postEvent(.quit) catch {}; } +/// How long a lone ESC waits for the rest of a sequence before it counts as +/// the Escape key. Long enough for the remainder of a real sequence to arrive +/// even over a slow link, short enough that nobody sees the delay. +const escape_hold_ms = 25; + +/// Is there more input right behind what we have already read? Only a real +/// terminal has an fd to ask; the test readers hand their parts over whole, so +/// for them the answer is always no. +fn morePending(tty: anytype) bool { + const Reader = @typeInfo(@TypeOf(tty)).pointer.child; + if (!@hasField(Reader, "fd") or @FieldType(Reader, "fd") != std.Io.File) return false; + var fds = [_]std.posix.pollfd{.{ .fd = tty.fd.handle, .events = std.posix.POLL.IN, .revents = 0 }}; + return (std.posix.poll(&fds, escape_hold_ms) catch return false) > 0; +} + fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { try loop.postEvent(.{ .winsize = try tty.getWinsize() }); var parser: vaxis.Parser = .{}; @@ -101,6 +116,22 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } carried = end - consumed; std.mem.copyForwards(u8, buf[0..carried], buf[consumed..end]); + // A lone ESC opens most sequences and is also the Escape key, so the + // parser holds it for a remainder that a keypress never sends: the + // press would only land when the NEXT key arrived. Wait a beat, and + // when nothing follows it was the key. A terminal speaking the kitty + // protocol never reaches here — it spells Escape out in full. + if (carried == 1 and buf[0] == 0x1b and !morePending(tty)) { + carried = 0; + try vaxis.loop.handleEventGeneric( + loop, + loop.vaxis, + cache, + @TypeOf(Command.value), + @as(vaxis.Event, .{ .key_press = .{ .codepoint = vaxis.Key.escape } }), + loop.vaxis.opts.system_clipboard_allocator, + ); + } } } diff --git a/src/tutor.txt b/src/tutor.txt index 394a48a3..adf4ae3a 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -160,8 +160,10 @@ RAW INPUT Ctrl-b switches to editor mode. Other keys go to the child, - including Ctrl-o, Ctrl-w, Ctrl-V, Ctrl-Shift-V, Alt shortcuts, and - modified Escape. Plain Esc at a detected shell prompt hops + including Ctrl-o, Ctrl-w, Alt shortcuts, and + modified Escape. The two paste chords are the exception: Ctrl-V + types the yank register at the program and Ctrl-Shift-V types the + desktop clipboard. Plain Esc at a detected shell prompt hops to the previous pane. While a program owns the terminal, Esc goes to that program too. Use the Togglettymode tag to leave raw input in place. Desktop paste events still feed the child. @@ -387,15 +389,15 @@ typed pardes the 9P socket opens by default - A directory per pane holding `name`, `body`, `tag`, `ctl`, `addr`, - `data`, `sel`, `event`, `pty/` and the rest, plus `index`, `ctl`, - `new` and `log` at the root. Every pane shell receives `$PARDES_9P` - (the socket) and `$PARDES_PANE` (its serial). Use a 9P client: + A directory per pane holding `name`, `body`, `tag`, `addr`, `dot`, + `data`, `sel`, `dirty`, `event`, `pty/` and the rest, plus `index`, + `status`, `look`, `exec` and `log` at the root. Every pane shell + receives `$PARDES_9P` (the socket) and `$PARDES_PANE` (its serial): /pane//body /index - /new reading it makes a pane, answers its serial - /ctl `look FILE:12` and `exec Save`, as the mouse does + /pane mkdir here makes a pane, rmdir closes it + /look /exec `FILE:12` and `Save`, as the mouse does A terminal pane also has `pty/`: @@ -446,7 +448,8 @@ typed Esc goes to the program — except at a shell PROMPT, where it hops to the previous pane Ctrl-b switches to editor mode - All other keys belong to the child, including Ctrl-V + Ctrl-V types the register, Ctrl-Shift-V the clipboard + All other keys belong to the child DETACHED pardes --detach[=name] the core, no terminal pardes --attach[=name] a frontend for it diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py index 0fa12136..48c0ea1b 100644 --- a/test/appearance_gallery.py +++ b/test/appearance_gallery.py @@ -82,7 +82,7 @@ def run(args): right = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'right column missing') client.write(f'/pane/{right}/name', f'{root / "Review"}\n'.encode()) client.write(f'/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True) - client.write(f'/pane/{right}/ctl', b'clean\n') + client.write(f'/pane/{right}/dirty', b'0\n') before = set(panes(client)) command(client, right, 'Tty') terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing') @@ -94,12 +94,12 @@ def run(args): command(client, right, 'Config') pane = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Config output missing') text = client.read(f'/pane/{pane}/body').decode() - client.write(f'/pane/{pane}/ctl', b'exec Del\n') + client.write(f'/pane/{pane}/exec', b'Del\n') return text settings = config_report() - require('Font effective size: 18.00 pixels' in settings, 'configured font size not applied: ' + settings) - require('TaglineSize: 80' in settings, 'configured tag size missing') + require('Font effective size 18.00 pixels' in settings, 'configured font size not applied: ' + settings) + require('TaglineSize 80' in settings, 'configured tag size missing') (root / 'effective-config.txt').write_text(settings) report['font_effective_pixels'] = 18 report['tagline_percent'] = 80 @@ -135,7 +135,7 @@ def run(args): command(client, right, 'Theme ' + theme) if args.theme_selector: # Keep both quiet active gutter ink and vivid comments in view. - client.write(f'/pane/{source}/ctl', f'look {source_path}:117\n'.encode()) + client.write(f'/pane/{source}/look', f'{source_path}:117\n'.encode()) take_frame('classic-' + theme) if args.theme_selector: frame = client.screen() @@ -171,14 +171,14 @@ def run(args): 'ThemeSel did not open at native first entry') take_frame('theme-selector') report['native_themes_first'] = True - client.write(f'/pane/{selector}/ctl', b'exec Del\n') + client.write(f'/pane/{selector}/exec', b'Del\n') command(client, terminal, 'Collapse') for pet in (() if args.skip_pets else ('cat', 'frog', 'off')): command(client, right, 'Pet ' + pet) settings = config_report() require('Pet: ' + pet + '\n' in settings, 'pet setting not applied') # Returning focus to the source closes all transient report state. - client.write(f'/pane/{source}/ctl', f'look {source_path}:1\n'.encode()) + client.write(f'/pane/{source}/look', f'{source_path}:1\n'.encode()) take_frame('pet-' + pet) if pet != 'off': frame = client.screen() diff --git a/test/column_tags.py b/test/column_tags.py index 80b623bd..fe3a8699 100644 --- a/test/column_tags.py +++ b/test/column_tags.py @@ -143,7 +143,7 @@ def run(args): client.write(f'/pane/{right}/name', f'{right_name}\n'.encode()) right_body = b'// Column-local scratch\nfn main() {\n let ready = true;\n}\n' client.write(f'/pane/{right}/body', right_body) - client.write(f'/pane/{right}/ctl', b'clean\n') + client.write(f'/pane/{right}/dirty', b'0\n') require(wait_for(lambda: locate(client, 'New Tty', row=1, minimum_col=75)), 'right column tag missing') require(locate(client, 'New Tty', row=1, minimum_col=75) == (75, 1), 'right column tag retains an inset') @@ -173,7 +173,7 @@ def run(args): extra = require(wait_for(lambda: new_serial(client, before)), 'right-column New did not create a pane') client.write(f'/pane/{extra}/body', b'RIGHT-COLUMN-NEW\n') require(wait_for(lambda: locate(client, 'RIGHT-COLUMN-NEW', minimum_col=75)), 'right-column command targeted another column') - client.write(f'/pane/{extra}/ctl', b'exec Del\n') + client.write(f'/pane/{extra}/exec', b'Del\n') checks.append('middle-click New targets the inactive right column') # An owned short-path file makes its complete tag visible. Pane-tag diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index f7f380df..5a181b78 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -92,7 +92,7 @@ pub const Harness = struct { const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { - for ([_][*:0]const u8{ "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }) |name| + for ([_][*:0]const u8{ "PARDES_PID", "PARDES_9P", "PARDES_PANE" }) |name| if (unsetenv(name) != 0) _exit(126); _ = execvp(exe, argv.ptr); _exit(127); diff --git a/test/font_size.py b/test/font_size.py index 3a0a9a28..b7104099 100644 --- a/test/font_size.py +++ b/test/font_size.py @@ -30,16 +30,16 @@ def run(args): for serial, tag in panes(client).items(): if '/+Config ' in tag: body = client.read(f'/pane/{serial}/body').decode() - if f'Font effective size: {size:.2f} pixels' in body: + if f'Font effective size {size:.2f} pixels' in body: return body return None return require(wait_for(ready), f'Font did not become {size} pixels') initial = report(18) - require('Font requested size: 18.00' in initial, 'startup size suffix ignored') + require('Font requested size 18.00' in initial, 'startup size suffix ignored') require(args.font in initial, 'requested font missing') execute(client, control, f'Font {args.font}:20.5') - require('Font requested size: 20.50' in report(20.5), 'fractional size ignored') + require('Font requested size 20.50' in report(20.5), 'fractional size ignored') execute(client, control, f'Font {args.font}') report(20.5) # no suffix preserves current size execute(client, control, f'Font {args.font}:900') diff --git a/test/fs.py b/test/fs.py index 819a78d6..c3ffa440 100644 --- a/test/fs.py +++ b/test/fs.py @@ -100,8 +100,15 @@ def session(binary, root, name, *options, socket_name=None, file=None, tty=False assert not reader.is_alive(), 'terminal reader did not stop' +def newest(client): + """The newest pane's serial: /index is ordered by serial.""" + return int(client.read('/index').splitlines()[-1].split()[0]) + + def new_pane(client, contents): - serial = int(client.read('/new').split()[0]) + """mkdir in /pane; the editor names the directory after the new serial.""" + client.mkdir('/pane', 'scratch') + serial = newest(client) if contents: client.write(f'/pane/{serial}/body', contents) return serial @@ -110,18 +117,18 @@ def new_pane(client, contents): def look(client, path, source=None): """A right click on `path` from a pane (a fresh scratch by default).""" serial = source if source is not None else new_pane(client, b'') - client.write(f'/pane/{serial}/ctl', f'look {path}\n'.encode()) - results = [int(row) for row in client.read('/ctl').split()] + client.write(f'/pane/{serial}/look', f'{path}\n'.encode()) + results = [int(row) for row in client.read('/look').split()] assert len(results) == 1 and results[0] != serial, (path, results) return results[0] def execute(client, serial, command): """A middle click on `command` from a pane.""" - client.write(f'/pane/{serial}/ctl', f'exec {command}\n'.encode()) + client.write(f'/pane/{serial}/exec', f'{command}\n'.encode()) -def walk_tree(client, path='/', skip=('/new', '/log', '/os', '/screen')): +def walk_tree(client, path='/', skip=('/log', '/os', '/screen')): """Stat every entry below `path`, as `find` does, without opening anything.""" seen = [] for name in client.list(path): @@ -138,18 +145,21 @@ def discovery(binary, embedded=False): root = Path(directory) with session(binary, root, 'discovery') as (client, _): top = client.list('/') - assert top[:9] == ['README', 'index', 'ctl', 'new', 'log', 'screen', 'listeners', 'pane', 'os'], top - assert 'self' not in top and 'cons' not in top, top + assert top[:10] == ['README', 'index', 'status', 'look', 'exec', 'log', 'screen', + 'listeners', 'pane', 'os'], top + assert 'self' not in top and 'cons' not in top and 'new' not in top, top assert ('src' in top) == embedded, (top, embedded) before = client.read('/index') guide = client.read('/README') - assert guide.count(b'\n') <= 45 and b'exec Msg' in guide and b'/new' in guide, guide + assert guide.count(b'\n') <= 45 and b'Msg hello' in guide and b'mkdir' in guide, guide assert client.stat('/README')['length'] == len(guide) # ls/stat/find over the whole tree, without opening, creates nothing. seen = walk_tree(client) names = {path for path, _ in seen} - assert {'/index', '/ctl', '/new', '/log', '/pane/1', '/pane/1/body', '/pane/1/name', - '/pane/1/sel', '/pane/1/ctl', '/pane/1/tag', '/pane/1/event'} <= names, sorted(names) + assert {'/index', '/status', '/look', '/exec', '/log', '/pane/1', '/pane/1/body', + '/pane/1/name', '/pane/1/sel', '/pane/1/ctl', '/pane/1/tag', '/pane/1/event', + '/pane/1/dot', '/pane/1/limit', '/pane/1/dirty', '/pane/1/mark', + '/pane/1/scroll', '/pane/1/look', '/pane/1/exec'} <= names, sorted(names) assert '/pane/1/pty' not in names, 'the fixture file pane grew a pty/' assert not any(name.startswith('/self') or name.endswith('/rdsel') or name.endswith('/wrsel') for name in names), sorted(names) @@ -159,18 +169,28 @@ def discovery(binary, embedded=False): assert client.read('/index') == before, 'browsing created a pane' assert client.stat('/index')['length'] == len(before) > 0 index = client.stat('/index') - assert index['mode'] == 0o444 and client.stat('/ctl')['mode'] == 0o666 + assert index['mode'] == 0o444 and client.stat('/status')['mode'] == 0o444 + assert client.stat('/exec')['mode'] == 0o666 assert client.stat('/pane/1/body')['mode'] == 0o666 and client.stat('/pane')['mode'] == 0o755 + # A range file reads the pair it takes; a flag file reads 0 or 1. + assert client.stat('/pane/1/addr')['length'] == 24 + assert client.stat('/pane/1/limit')['length'] == 0 + assert client.read('/pane/1/dirty') == b'0\n' + assert client.read('/pane/1/scroll') == b'1\n' fixture = int(before.split()[0]) assert before.split()[1:3] == [b'text', b'0'] and before.endswith(b'/discovery.txt\n'), before - # Before any command, /ctl reports the editor. - status = dict(line.split(maxsplit=1) for line in client.read('/ctl').decode().splitlines()) - assert int(status['pid']) > 0 and status['version'] and int(status['panes']) == 1, status - # /new: one pane per open, its serial is the file's content. + # /status reports the editor; /exec has nothing to report yet. + status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) + assert int(status['pid']) > 0 and status['version'] and int(status['panes']) >= 1, status + assert client.read('/exec') == b'' + # mkdir makes a pane, named by the serial the editor gives it. log = client.open('/log') - first = int(client.read('/new').split()[0]) - second = int(client.read('/new').split()[0]) + client.mkdir('/pane', 'one') + first = newest(client) + client.mkdir('/pane', 'two') + second = newest(client) assert first != second and first != fixture, (first, second) + assert 'one' not in client.list('/pane') and 'two' not in client.list('/pane') assert client.read_fid(log) == f'new {first} {root}/+New\n'.encode() assert client.read_fid(log) == f'new {second} {root}/+New\n'.encode() assert set(client.list('/pane')) == {str(fixture), str(first), str(second)} @@ -186,51 +206,63 @@ def discovery(binary, embedded=False): assert client.read(f'/pane/{first}/name') == f'{root}/notes.txt\n'.encode() assert client.read_fid(log) == f'rename {first} {root}/notes.txt\n'.encode() assert client.stat(f'/pane/{first}/name')['length'] == len(f'{root}/notes.txt\n') + # Copying addr onto dot selects that range; sel reads and replaces it. client.write(f'/pane/{first}/addr', b'#0,#5') - client.write(f'/pane/{first}/ctl', b'dot=addr\n') + client.write(f'/pane/{first}/dot', client.read(f'/pane/{first}/addr')) + assert client.read(f'/pane/{first}/dot') == client.read(f'/pane/{first}/addr') assert client.read(f'/pane/{first}/sel') == b'first' assert client.stat(f'/pane/{first}/sel')['length'] == 5 client.write(f'/pane/{first}/sel', b'FIRST') assert client.read(f'/pane/{first}/body') == b'FIRST pane' assert client.read(f'/pane/{first}/sel') == b'' - # exec Msg shows text; exec Save writes the named file; exec Del closes. - client.write(f'/pane/{first}/ctl', b'exec Msg hello from 9P\n') + # exec Msg shows text; exec Save writes the named file; rmdir closes. + client.write(f'/pane/{first}/exec', b'Msg hello from 9P\n') assert 'hello from 9P' in ''.join(cell[0] for cell in client.screen()['cells']) - client.write(f'/pane/{first}/ctl', b'exec Save\n') + assert client.read(f'/pane/{first}/dirty') == b'1\n' + client.write(f'/pane/{first}/exec', b'Save\n') assert (root / 'notes.txt').read_bytes() == b'FIRST pane' assert client.read_fid(log) == f'save {first} {root}/notes.txt\n'.encode() - assert client.read('/ctl') == f'{first}\n'.encode() - client.write('/ctl', b'exec Del\n') + assert client.read('/exec') == f'{first}\n'.encode() + assert client.read(f'/pane/{first}/dirty') == b'0\n' + client.remove(f'/pane/{second}') assert client.read_fid(log) == f'del {second} {root}/+New\n'.encode() assert str(second) not in client.list('/pane') - # look through /ctl opens a file; the serial reads back. + # look opens a file; the serial reads back. (root / 'opened.txt').write_bytes(b'one\ntwo\nthree\n') - client.write('/ctl', f'look {root}/opened.txt:2\n'.encode()) - opened = int(client.read('/ctl')) + client.write('/look', f'{root}/opened.txt:2\n'.encode()) + opened = int(client.read('/look')) assert opened not in (fixture, first, second), opened assert client.read(f'/pane/{opened}/body') == b'one\ntwo\nthree\n' assert client.read(f'/pane/{opened}/name') == f'{root}/opened.txt\n'.encode() assert client.read_fid(log) == f'new {opened} {root}/opened.txt\n'.encode() - client.write(f'/pane/{first}/ctl', f'look {root}/opened.txt:3\n'.encode()) - assert client.read('/ctl') == f'{opened}\n'.encode(), 'a repeated look targets the open pane' - assert set(client.list('/pane')) == {str(fixture), str(first), str(opened)} + client.write(f'/pane/{first}/look', f'{root}/opened.txt:3\n'.encode()) + assert client.read('/look') == f'{opened}\n'.encode(), 'a repeated look targets the open pane' + assert {str(fixture), str(first), str(opened)} <= set(client.list('/pane')) # Writing an event record back still performs the action it names. client.write(f'/pane/{first}/body', b'Msg via event', truncate=True) client.write(f'/pane/{first}/event', b'FX0 13\n') assert 'via event' in ''.join(cell[0] for cell in client.screen()['cells']) - for bad in [b'Look x\n', b'Exec Msg x\n', b'put\n', b'name x\n', b'del\n']: + # A command line may not carry control characters, and nothing else + # in the tree may be created or removed. + refused = [lambda: client.write('/exec', b'Msg a\x00b'), + lambda: client.write('/status', b'anything\n'), + lambda: client.mkdir('/', 'x'), + lambda: client.create('/pane', 'plain-file'), + lambda: client.remove('/pane/1/body'), + lambda: client.remove('/index')] + for index, attempt in enumerate(refused): try: - client.write('/ctl', bad) + attempt() except OSError: pass else: - raise AssertionError(f'{bad!r} was accepted') + raise AssertionError(f'refusal {index} was accepted') client.close(log) if embedded: assert client.read('/src/pardes.zig').startswith(b'const std') assert client.stat('/src/pardes.zig')['mode'] == 0o444 assert b'pub const Pardes' in client.read(f'/pane/{look(client, "/virtual/src/pardes.zig")}/body') - print('9P discovery: listing/stat/find are inert; new, ctl, name, sel and log behave') + print('9P discovery: listing/stat/find are inert; create, remove, look, exec, name, sel and log behave') def test(binary, quic=False): @@ -266,7 +298,7 @@ def test(binary, quic=False): assert not (root / 'pardes-9p-renamed.sock').exists() assert named.read('/pane/1/body') == b'initial\n' with session(binary, root, 'first') as (client, address): - assert {'pane', 'index', 'new', 'ctl', 'log', 'os'} <= set(client.list('/')) + assert {'pane', 'index', 'status', 'look', 'exec', 'log', 'os'} <= set(client.list('/')) assert 'self' not in client.list('/') assert client.read('/pane/1/body') == b'initial\n' assert client.read('/os' + str(root / 'first.txt')) == b'initial\n' @@ -346,7 +378,7 @@ def test(binary, quic=False): opened = look(remote, '/n/peer/pane/' + str(serial) + '/body') assert remote.read(f'/pane/{opened}/body') == b'replaced\nevent\n' remote.write(f'/pane/{opened}/body', b'saved to peer\n', truncate=True) - remote.write(f'/pane/{opened}/ctl', b'exec Save\n') + remote.write(f'/pane/{opened}/exec', b'Save\n') assert client.read(body) == b'saved to peer\n' large = b'mounted file contents\n' * 60000 @@ -356,7 +388,7 @@ def test(binary, quic=False): assert remote.read(f'/pane/{opened}/body') == large edited = large[:-3] + b'edited\n' remote.write(f'/pane/{opened}/body', edited, truncate=True) - remote.write(f'/pane/{opened}/ctl', b'exec Save\n') + remote.write(f'/pane/{opened}/exec', b'Save\n') assert large_path.read_bytes() == edited assert client.read('/os' + str(large_path)) == edited @@ -368,12 +400,12 @@ def test(binary, quic=False): execute(remote, control, 'Mount peer ' + str(own_address)) execute(remote, control, 'Unmount peer') remote.write(f'/pane/{opened}/body', b'runtime mounted Save\n', truncate=True) - remote.write(f'/pane/{opened}/ctl', b'exec Save\n') + remote.write(f'/pane/{opened}/exec', b'Save\n') assert client.read('/pane/1/body') == b'runtime mounted Save\n' assert remote.read('/pane/1/body') == b'initial\n' roots = look(remote, '/n', source=control) assert b'/n/peer/\n' in remote.read(f'/pane/{roots}/body') - remote.write(f'/pane/{opened}/ctl', b'exec Del\n') + remote.write(f'/pane/{opened}/exec', b'Del\n') execute(remote, control, 'Unmount peer') remote.write(f'/pane/{roots}/ctl', b'get\n') assert remote.read(f'/pane/{roots}/body') == b'/n/os/\n/n/self/\n' @@ -385,7 +417,7 @@ def test(binary, quic=False): file='/n/peer/pane/1/body:1') as (remote, _): assert remote.read('/pane/1/body') == b'runtime mounted Save\n' - client.write(f'/pane/{serial}/ctl', b'exec Del\n') + client.write(f'/pane/{serial}/exec', b'Del\n') assert str(serial) not in client.list('/pane') for tty in [False, True]: @@ -424,7 +456,7 @@ def test(binary, quic=False): file_id, control_id = restored_ids contents = restored.read(f'/pane/{file_id}/body') assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/index')) - assert restored.read(f'/pane/{control_id}/body') == b'', 'commands ran through ctl, not the body' + assert restored.read(f'/pane/{control_id}/body') == b'', 'commands ran through exec, not the body' assert restored.screen()['cols'] == 80 opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id) assert restored.read(f'/pane/{opened}/body') == b'initial\n' @@ -451,14 +483,12 @@ def test(binary, quic=False): assert client.read(f'/pane/{opened}/body') == expected assert client.read('/pane/1/body') == b'initial\n' if selected is not None: - addr = client.open(f'/pane/{opened}/addr') - client.write(f'/pane/{opened}/ctl', b'addr=dot\n') - actual = list(map(int, client.read_fid(addr).split())) - client.close(addr) + # The selection is dot; reading it takes no command at all. + actual = list(map(int, client.read(f'/pane/{opened}/dot').split())) assert actual == selected, (word, actual, selected) addr = client.open('/pane/1/addr', 2) - ctl = client.open('/pane/1/ctl', 1) - for fid, text in [(addr, b'#0,#3'), (ctl, b'limit=addr\n'), (addr, b'#0')]: + limit = client.open('/pane/1/limit', 2) + for fid, text in [(addr, b'#0,#3'), (limit, b'#0,#3'), (addr, b'#0')]: client.rpc(118, struct.pack(' req.size) return error.IncorrectReply; - const names = [_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }; - const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.ctl), @intFromEnum(ctlfs.TopFile.new), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root }; + const names = [_][]const u8{ "README", "index", "status", "look", "exec", "log", "screen", "listeners", "pane", "os" }; + const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.status), @intFromEnum(ctlfs.TopFile.look), @intFromEnum(ctlfs.TopFile.exec), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root }; var off: usize = 0; var entry: usize = 0; while (off < bytes.len) : (entry += 1) { diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig index 8b6f245e..3eba6639 100644 --- a/test/fs_namespace.zig +++ b/test/fs_namespace.zig @@ -245,22 +245,17 @@ test "virtual file opens do not request OS watches" { while (p.nextEffect()) |effect| if (effect == .watch) return error.VirtualFileWatch; } -test "Look at the clone file reserves its own pane and failed opens release it" { +test "Look at a virtual file opens one pane and failed opens release their slot" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - p.lookAt(0, "/virtual/new"); - var control_count: usize = 0; - var scratch_count: usize = 0; + p.lookAt(0, "/virtual/index"); + var opened: usize = 0; for (p.panes) |slot| { const pane = slot orelse continue; const file = pane.file orelse continue; - if (std.mem.eql(u8, file.path, "/virtual/new")) control_count += 1; - if (file.output) |output| if (output.from == .cmd and output.from.cmd == .New) { - scratch_count += 1; - }; + if (std.mem.eql(u8, file.path, "/virtual/index")) opened += 1; } - try std.testing.expectEqual(@as(usize, 1), control_count); - try std.testing.expectEqual(@as(usize, 1), scratch_count); + try std.testing.expectEqual(@as(usize, 1), opened); const free = p.freeSlot().?; try std.testing.expectError(error.FileNotFound, pardes.panes.File.open(p, free, "/n/self/does-not-exist", 0)); try std.testing.expect(p.panes[free] == null); diff --git a/test/fs_soak.py b/test/fs_soak.py index 5569a7c7..a62f03e6 100644 --- a/test/fs_soak.py +++ b/test/fs_soak.py @@ -145,7 +145,7 @@ def run(args): client.write(base + '/body', body, truncate=True) path = root / f'pane-{slot}-0.zig' client.write(base + '/name', f'{path}\n'.encode()) - client.write(base + '/ctl', b'exec Save\n') + client.write(base + '/exec', b'Save\n') panes.append({'serial': serial, 'body': body, 'path': path, 'variant': 0}) emit(kind='start', seed=args.seed, session_pid=pid, transport=args.transport, host='tty' if args.tty else 'detached', @@ -187,7 +187,7 @@ def run(args): pane['variant'] ^= 1 pane['path'] = root / f'pane-{slot}-{pane["variant"]}.zig' client.write(base + '/name', f'{pane["path"]}\n'.encode()) - client.write(base + '/ctl', b'exec Save\n') + client.write(base + '/exec', b'Save\n') check_bytes(pane['path'].read_bytes(), pane['body'], 'saved file') check_bytes(client.read('/os' + str(pane['path'])), pane['body'], 'OS mount') elif operation == 'reload': @@ -196,13 +196,13 @@ def run(args): client.write(base + '/ctl', b'get\n') elif operation == 'cycle': old_serial = pane['serial'] - client.write(base + '/ctl', b'exec Del\n') + client.remove(base) pane['body'] = payload(rng) pane['serial'] = new_pane(client, pane['body']) assert pane['serial'] != old_serial base = f'/pane/{pane["serial"]}' client.write(base + '/name', f'{pane["path"]}\n'.encode()) - client.write(base + '/ctl', b'exec Save\n') + client.write(base + '/exec', b'Save\n') elif operation == 'reconnect': client.socket.close() client = Client(address) diff --git a/test/look_placement.py b/test/look_placement.py index 1341e776..8a31157f 100644 --- a/test/look_placement.py +++ b/test/look_placement.py @@ -74,7 +74,7 @@ def run_case(args, root, source_index): require('pty' in client.list(pane), 'fixture contains an existing document') marker = f'LP_{index}' markers[serial] = marker - client.write(pane + '/ctl', b'cleartag\n') + client.write(pane + '/tag', b'', truncate=True) client.write(pane + '/tag', f' {marker} Tty Del Collapse'.encode()) # Establish non-pristine shells and a stable prompt before measuring. client.write(pane + '/pty/data', f"printf 'ready-{index}\\n'\r".encode()) @@ -89,7 +89,7 @@ def run_case(args, root, source_index): require(before[serials[0]][:2] == (0, 2) and before[serials[1]][0] == 0 and before[serials[1]][1] > 2 and before[serials[2]][:2] == (75, 2), f'unexpected initial classic layout: {before}') - client.write(f'/pane/{source}/ctl', f'look {target}\n'.encode()) + client.write(f'/pane/{source}/look', f'{target}\n'.encode()) opened = require(wait_for(lambda: next((serial for serial in panes(client) if serial not in serials), None)), 'Look did not open the first document') @@ -113,7 +113,7 @@ def run_case(args, root, source_index): f'Look changed unrelated terminal {serial} rendered text') require(client.read(f'/pane/{opened}/body') == contents, 'opened document contents differ') - client.write(f'/pane/{source}/ctl', f'look {target}:2\n'.encode()) + client.write(f'/pane/{source}/look', f'{target}:2\n'.encode()) require(list(panes(client)) == serials + [opened], 'repeated Look created a duplicate pane') require(wait_for(lambda: geometry(client, markers)[1] == after), 'repeated Look changed layout') diff --git a/test/ninep.py b/test/ninep.py index 6c625951..ac4e146b 100644 --- a/test/ninep.py +++ b/test/ninep.py @@ -97,6 +97,25 @@ class Client: def close(self, fid): self.rpc(120, struct.pack(' |w| { + const bytes = w.bytes.slice(); + if (n + bytes.len > buf.len) break; + @memcpy(buf[n..][0..bytes.len], bytes); + n += bytes.len; + }, + else => {}, + }; + return buf[0..n]; + } + + // Raw tty mode forwards nearly every key to the child; the two paste + // chords are the exception, and forwarding them instead is what broke + // pasting into a terminal pane. An application that gets Ctrl-V rather + // than the text goes and reads the desktop clipboard on its own account, + // and a coding agent reading it is looking for an image, not for words. + test "raw tty paste chords type at the program instead of reaching it as keys" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + for ([_]bool{ false, true }) |bracketed| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + if (bracketed) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); + try std.testing.expectEqual(bracketed, panes.Terminal.bracketedPaste(pane)); + p.yank = try p.gpa.dupe(u8, "one\ntwo"); // what a `y` anywhere left behind + var buf: [256]u8 = undefined; + _ = childInput(p, &buf); + + // Ctrl-V types the register straight at the program, asking the + // desktop for nothing. Under DECSET 2004 the program's own fences + // carry it, which is what stops readline RUNNING a multi-line paste. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings( + if (bracketed) "\x1b[200~one\ntwo\x1b[201~" else "one\rtwo", + childInput(p, &buf), + ); + try std.testing.expect(p.clip_pending == null); + + // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and + // the answer lands at the program rather than in an edit buffer. A + // host that folds the Shift into the letter says the same thing. + for ([_]Key{ + .{ .cp = 'v', .ctrl = true, .shift = true }, + .{ .cp = 'V', .ctrl = true }, + }) |chord| { + p.update(.{ .key = chord }); + try std.testing.expect(p.clip_pending != null); + try std.testing.expectEqualStrings("", childInput(p, &buf)); + p.update(.{ .paste = "from-desktop" }); + try std.testing.expect(p.clip_pending == null); + try std.testing.expectEqualStrings( + if (bracketed) "\x1b[200~from-desktop\x1b[201~" else "from-desktop", + childInput(p, &buf), + ); + } + // ...and neither chord put the clipboard in the register on its way. + try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); + } + } }; const ClickCursorTests = struct { @@ -1616,7 +1682,7 @@ test "generated output refuses every pipe prompt before seeding the tag" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const content = try p.gpa.dupe(u8, "generated\n"); - const pane = try panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", content); + const pane = try panes.Output.open(p, p.freeSlot().?, "/", .{ .cmd = .Help }, "", content); p.active = 1; for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| { p.update(.{ .key = key }); @@ -1825,14 +1891,14 @@ test "file history construction failures preserve existing panes" { defer if (incoming) |bytes| p.gpa.free(bytes); failing.fail_index = failing.alloc_index + failure; const result = switch (case) { - .open => panes.File.open(p, 1, path, 0), + .open => panes.File.open(p, p.freeSlot().?, path, 0), .restore => panes.File.restore(p, 1, .{ .kind = .file, .tag = "", .body = "", .file = .{ .path = "/restored.zig", .content = "restored\n" }, }), - .output => panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", incoming.?), + .output => panes.Output.open(p, p.freeSlot().?, "/", .{ .cmd = .Help }, "", incoming.?), .tutor => p.openTutorView(1), .replace => p.setTestFile("replacement\n"), }; @@ -1900,14 +1966,14 @@ test "file history owns bounded snapshot storage without per-edit bookkeeping al try std.testing.expectEqual(@as(usize, 0), history.redo_len); } -test "file history allocation precedes a filesystem read with side effects" { +test "file history allocation precedes reading the served tree" { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); const original = p.panes[0].?; const serial = p.next_serial; failing.fail_index = failing.alloc_index + 2; - try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new", 0)); + try std.testing.expectError(error.OutOfMemory, panes.File.open(p, p.freeSlot().?, "/n/self/index", 0)); try std.testing.expect(failing.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqual(serial, p.next_serial); diff --git a/test/pdf.zig b/test/pdf.zig index beeb0b40..930cc5c0 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1253,7 +1253,7 @@ test "PdfTint cycles pane-local state without adding tag clutter" { try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.startsWith(u8, initial_tag, pv.path)); - try std.testing.expect(std.mem.endsWith(u8, initial_tag, " PdfSections PdfTint Collapse")); + try std.testing.expect(std.mem.endsWith(u8, initial_tag, " PdfSections PdfTint Collapse Del")); try std.testing.expect(std.mem.indexOf(u8, initial_tag, "filtered") == null); try std.testing.expect(pardes.test_api.runBuiltin( diff --git a/test/tag_cleanup.py b/test/tag_cleanup.py index 7b7d8bbf..ae0a0e66 100644 --- a/test/tag_cleanup.py +++ b/test/tag_cleanup.py @@ -36,7 +36,7 @@ def run(args): before = set(panes(client)) execute(client, control, 'Tty') tty = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing') - client.write(f'/pane/{control}/ctl', b'exec Del\n') # owned command-only scratch + client.write(f'/pane/{control}/exec', b'Del\n') # owned command-only scratch def tag_position(): frame = client.screen() diff --git a/test/ui_review.py b/test/ui_review.py index 2b6faa29..a1cdcdeb 100644 --- a/test/ui_review.py +++ b/test/ui_review.py @@ -43,7 +43,7 @@ def command(client, serial, text): current = client.read(tag) start = current.rfind(text.encode()) client.write(f'/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode()) - client.write(f'/pane/{serial}/ctl', b'cleartag\n') + client.write(f'/pane/{serial}/tag', b'', truncate=True) client.write(tag, before[tail_start:]) def new_output(client, before): @@ -111,7 +111,7 @@ def run(args): capture(client, root, '02-find', not args.tty) if find_pane: (root / 'find-results.txt').write_bytes(client.read(f'/pane/{find_pane}/body')) - client.write(f'/pane/{find_pane}/ctl', b'exec Del\n') + client.write(f'/pane/{find_pane}/exec', b'Del\n') before = panes(client) command(client, source, 'Grep CrdsValueLabel') grep_pane = require(wait_for(lambda: new_output(client, before)), 'Grep did not return results') @@ -140,10 +140,10 @@ def run(args): (root / 'symbols-results.txt').write_bytes(client.read(f'/pane/{symbols}/body')) capture(client, root, '05-symbols', not args.tty) if symbols: - client.write(f'/pane/{symbols}/ctl', b'exec Del\n') + client.write(f'/pane/{symbols}/exec', b'Del\n') offset = original.index(b'CrdsValue {') client.write(f'/pane/{source}/addr', f'#{offset},#{offset}'.encode()) - client.write(f'/pane/{source}/ctl', b'dot=addr\nshow\n') + client.write(f'/pane/{source}/dot', client.read(f'/pane/{source}/addr')) before = panes(client) command(client, source, 'Hover') hover = wait_for(lambda: new_output(client, before), args.lsp_wait) @@ -154,7 +154,7 @@ def run(args): # Look focuses the source without changing it; gd then travels through # the host's real input parser, unlike tag builtin dispatch above. line = original[:original.index(b'impl Sanitize for CrdsValue')].count(b'\n') + 1 - client.write(f'/pane/{source}/ctl', f'look {source_path}:{line}:19\n'.encode()) + client.write(f'/pane/{source}/look', f'{source_path}:{line}:19\n'.encode()) os.write(terminal_handle['input_fd'], b'gd') time.sleep(1) capture(client, root, '07-definition', not args.tty) @@ -166,7 +166,7 @@ def run(args): report['lsp'] = info.decode() capture(client, root, '08-lsp-info', not args.tty) if lsp: - client.write(f'/pane/{lsp}/ctl', b'exec Del\n') + client.write(f'/pane/{lsp}/exec', b'Del\n') for theme in args.themes: command(client, source, 'Theme ' + theme) capture(client, root, 'theme-' + theme, not args.tty) diff --git a/test/v9fs.py b/test/v9fs.py index 16916d0b..fa35388f 100644 --- a/test/v9fs.py +++ b/test/v9fs.py @@ -43,8 +43,8 @@ def worker(mountpoint, socket, uid, gid, original_namespace): assert not any(field.startswith(('shared:', 'master:')) for field in mounted[0].split()[6:]) tree = mountpoint - assert {'os', 'index', 'pane', 'new', 'ctl', 'log', 'screen', 'README'} <= set(os.listdir(tree)) - assert 'self' not in os.listdir(tree) + assert {'os', 'index', 'pane', 'status', 'look', 'exec', 'log', 'screen', 'README'} <= set(os.listdir(tree)) + assert 'self' not in os.listdir(tree) and 'new' not in os.listdir(tree) # A direct connection provides independent evidence for VFS reads/writes. with Client(socket) as client: assert (tree / 'index').read_bytes() == client.read('/index') @@ -52,14 +52,31 @@ def worker(mountpoint, socket, uid, gid, original_namespace): before = client.read('/index') subprocess.run(['ls', '-l', str(tree), str(tree / 'pane' / '1')], check=True, capture_output=True, timeout=5) - subprocess.run(['find', str(tree / 'pane'), str(tree / 'new'), '-ls'], check=True, capture_output=True, timeout=5) + subprocess.run(['find', str(tree / 'pane'), '-ls'], check=True, capture_output=True, timeout=5) assert (tree / 'README').read_bytes() == client.read('/README') - assert (tree / 'new').stat().st_size == 0 assert client.read('/index') == before, 'browsing created panes' - serial = int((tree / 'new').read_bytes().split()[0]) - another = int((tree / 'new').read_bytes().split()[0]) - assert serial != another, 'cached factory reused a pane' - client.write(f'/pane/{another}/ctl', b'exec Del\n') + # mkdir through the kernel mount opens a pane. The editor names it + # after its serial, not after the name asked for, so the kernel's own + # revalidation of that name may fail; the index is the answer. + def serials(): + return {int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()} + + def mkpane(name): + known = serials() + try: + (tree / 'pane' / name).mkdir() + except FileNotFoundError: + pass + made = serials() - known + assert len(made) == 1, (name, made) + return made.pop() + + serial = mkpane('kernel-made') + another = mkpane('kernel-made-again') + assert serial != another, 'a second mkdir reused a pane' + assert not (tree / 'pane' / 'kernel-made').exists() + (tree / 'pane' / str(another)).rmdir() + assert another not in serials() pane = tree / 'pane' / str(serial) wire = f'/pane/{serial}' assert str(serial) in os.listdir(tree / 'pane') @@ -101,7 +118,7 @@ def worker(mountpoint, socket, uid, gid, original_namespace): # Reading OS files through the exported tree does not recurse through # the mount: the core still lives in the supervisor's namespace. assert (mountpoint / 'os' / str(socket.parent).lstrip('/') / 'kernel.txt').read_bytes() == b'initial\n' - write_existing(pane / 'ctl', b'exec Del\n') + (tree / 'pane' / str(serial)).rmdir() assert serial not in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()] print('v9fs: namespace isolation, privilege drop, inherited mount, directory refresh, ' diff --git a/test/window_opacity.py b/test/window_opacity.py index b9529373..a576b1bd 100644 --- a/test/window_opacity.py +++ b/test/window_opacity.py @@ -60,7 +60,7 @@ def run(args): pane = require(wait_for(lambda: next((serial for serial in panes(client) if serial not in before), None)), builtin + ' output missing') text = client.read(f'/pane/{pane}/body').decode() - client.write(f'/pane/{pane}/ctl', b'exec Del\n') + client.write(f'/pane/{pane}/exec', b'Del\n') return text help_text = output('Help') -- cgit v1.3