summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.agents/skills/pardes-9p/SKILL.md139
-rw-r--r--build.zig19
-rw-r--r--docs/config.md9
-rw-r--r--docs/fs.md371
-rw-r--r--docs/mvzr-group-backtrack.md22
-rw-r--r--docs/tags.md37
-rw-r--r--src/9p_io.zig19
-rw-r--r--src/File.zig53
-rw-r--r--src/Messages.zig53
-rw-r--r--src/Output.zig4
-rw-r--r--src/builtins.zig265
-rw-r--r--src/config.zig125
-rw-r--r--src/detached/server.zig22
-rw-r--r--src/dump.zig36
-rw-r--r--src/exec.zig168
-rw-r--r--src/file_watch.zig58
-rw-r--r--src/fs-help.txt38
-rw-r--r--src/fs.zig23
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host_io.zig76
-rw-r--r--src/layout.zig201
-rw-r--r--src/look.zig174
-rw-r--r--src/modal.zig52
-rw-r--r--src/mouse.zig87
-rw-r--r--src/ninep/addr.zig188
-rw-r--r--src/ninep/cols.zig124
-rw-r--r--src/ninep/ctl.zig847
-rw-r--r--src/ninep/events.zig322
-rw-r--r--src/ninep/pane.zig146
-rw-r--r--src/ninep/pty.zig49
-rw-r--r--src/ninep/tree.zig309
-rw-r--r--src/pardes.zig256
-rw-r--r--src/regexp.zig17
-rw-r--r--src/sam_edit.zig59
-rw-r--r--src/tty/tty.zig7
-rw-r--r--test/fs.py99
-rw-r--r--test/fs_namespace.zig12
-rw-r--r--test/gui-goldens.txt8
-rw-r--r--test/macos_e2e.swift2
-rw-r--r--test/output.zig11
-rw-r--r--test/panes.zig53
-rw-r--r--test/selfmount.py33
-rw-r--r--test/snapshot.zig5
-rw-r--r--test/snapshots/argv.golden2
-rw-r--r--test/snapshots/cornerdrag.golden8
-rw-r--r--test/snapshots/filewatch.golden31
-rw-r--r--test/snapshots/filewatch.snap19
-rw-r--r--test/snapshots/jumps.golden4
-rw-r--r--test/snapshots/lookpanes.golden34
-rw-r--r--test/snapshots/nested.golden6
-rw-r--r--test/snapshots/psearch.golden10
-rw-r--r--test/snapshots/psearch.snap8
-rw-r--r--test/snapshots/reflow.golden2
-rw-r--r--test/snapshots/rsearch.snap3
-rw-r--r--test/snapshots/shellset.golden2
-rw-r--r--test/snapshots/shellset.snap12
-rw-r--r--test/snapshots/shrinktag.golden20
-rw-r--r--test/snapshots/shrinktag.snap20
-rw-r--r--test/snapshots/splitparent.golden42
-rw-r--r--test/snapshots/splitparent.snap18
-rw-r--r--test/snapshots/tinywin.golden18
-rw-r--r--test/snapshots/ttyhelp.golden2
-rw-r--r--test/snapshots/ttylook.golden2
-rw-r--r--test/snapshots/ttyonly.golden4
64 files changed, 4141 insertions, 732 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 844e028f..7f33937f 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -64,40 +64,58 @@ $m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `e
(a fresh terminal: waits for its first prompt; if none comes, interrupt the read)
$m/log recent events, then EOF: new|del|rename|save <serial> <name>, msg <serial|-> <text>,
run <serial> <line>, exit <serial> <N|?>, send <from> <to> <repl-id>,
+ ask <serial> <what> <choices>, answer <serial> <choice|->, changed <serial>
+ [reloaded|deleted] (under unsaved edits; a clean reload; gone from disk),
+ unsaved <serial> <name> (each pane a refusal is about), newcol|delcol <serial>, restored|restoredcol <old> <new>,
dump|restore <path>, err <serial|-> <file>: <why>
- (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not;
- while read -r line <&3; do ...; done loses nothing)
+ (exec 3<>$m/log; echo follow >&3; cat <&3 replays what is there, then waits for new
+ ones; `echo follow new` waits for new ones only; tail -f does not wait;
+ while read -r line <&3; do ...; done loses nothing within a session (through
+ FUSE, `read -t` never times out: use `timeout N` around the loop): a Restore
+ hangs every connection up, so dial again, and restart a 9ns mount)
$m/screen the rendered screen as JSON, frozen per open
$m/listeners this session's dial addresses
$m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it);
echo a serial into it to move the keyboard (a folded pane stays folded)
-$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin
+$m/ctl the settings, one a line as a write takes them; write a setting or a session builtin;
+ `size <cols> <rows>` sizes a --detach session no frontend is attached to (160x50 before)
(Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the
commands pardes started (command panes, lines it typed into shells), a word
matching a command line's first word; Exit and Restore refuse once,
- naming every unsaved pane, `<a>, <b>: Modified (Exit again to discard them all)`, and the same
- word again DISCARDS that text -- not a retry, unlike lock's `file in use`;
- Kill signals only the foreground job, so of `sleep 30; echo done` the
- echo still runs; Joincol needs a column to the right of the keyboard's);
+ logging `unsaved <serial> <name>` for each unsaved pane, then failing the write with
+ `<name>: Modified (Exit again to discard)` for one or `4 unsaved panes: Modified (Exit
+ again to discard)` for more (Restore's says `Restore again`); a second refusal names only the panes edited since the last,
+ as acme's does, and the same word again with nothing edited since DISCARDS them all -- not a retry, unlike lock's `file in use`;
+ Kill stops a command pane's whole line, `&` jobs included; of a line typed into a
+ shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done`
+ bash and fish both run the echo); Joincol folds the keyboard's column into the one
+ on its right, its panes going below that column's own, in order, and needs such a column);
a pane's builtins (Del, Save f, Collapse, which folds that pane, and the
column word Delcol, which closes that pane's column) go to $m/pane/<n>/ctl
-$m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it)
-$m/layout one line per column: serial index x width current|notcurrent empty|full pane-serials...; active <serial>
+$m/commands every builtin: `Word`, `Word arg`, then `root`, `pane` or `both` (which ctl takes
+ it), a setting's values comma-joined, then ` -- ` and one sentence of what it does
+$m/layout one line per column (16 columns at most: Newcol past that fails, no space for a column): serial index x width current|notcurrent empty|full pane-serials...; active <serial>
$m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<serial>/tag a column's (serials stay, as panes' do)
+$m/tagexec a word as a click in the workspace tag (pane/<n>/tagexec: in that pane's tag); every exec
+ file reads back the serials its last write touched
$m/col/<serial>/ctl Delcol, Joincol, New, Tty on that column; col/<serial>/exec a word as a click in its tag;
rmdir col/<serial> closes an empty column
$m/pane/new open it to make a pane (a scratch named <dir>/+New), read names it; it goes in
the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if
empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules);
- rmdir $m/pane/<n> closes it; a column's last pane leaves the column EMPTY
+ a session holds 64 panes (16 on the board): past that, every route that opens one
+ fails with `no space for a pane: 64 max` (ENOSPC), and so does one whose column
+ has no room (each pane keeps its tag and 2 rows: `no space for a pane in that column`); rmdir $m/pane/<n> closes it; a column's last pane leaves the column EMPTY
(focus reads empty, the log says only del), and the session's LAST pane QUITS it
$m/pane/<n>/errors write-only: text appended to the +Errors pane of the pane's directory
$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,
+number. A row is `serial kind dirty name column`, the last word the pane's
+column serial, so `head, col = row.rsplit(maxsplit=1)` then
+`serial, kind, dirty, name = head.split(maxsplit=3)` keeps a name with spaces
+intact. Re-read the index after anything that might open,
reuse or close a pane.
```sh
@@ -135,12 +153,23 @@ created or removed, and no read creates anything.
`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/<n>/look` and
`$m/pane/<n>/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. A word no builtin
+made, or the pane it focused or acted on: an open's own last write's, or, on
+an open that never wrote, the session's last. With other clients about,
+write and read one open (`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`).
+A read is a stream: once read, a second read on the same fd gives EOF (on
+an open that wrote, until its next write); open again, or seek to 0, to read
+it again. Each command line runs once whole, however a mount cuts a big write; a last
+line with no newline runs when the open closes, and an Edit block still open
+then fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.`
+line), changing nothing. One rule: a builtin that fails, whether through a
+ctl, look, exec, tagexec or a column's exec, fails the write (EINVAL for a
+malformed line, else EIO or an errno that fits) and logs one `err` with the
+reason, no `msg`. A look that finds nothing is no failure: it answers
+nothing and logs one `err`. A command line run in a command pane is judged
+by its `exit` record. A word no builtin
knows (a typo included) is a command line: written at a terminal at its
prompt it is typed into that shell; from anywhere else it runs as a command
-pane, a terminal whose child is the root ctl's `Shell` (fish unless set)
+pane, a terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set)
running `-c` the line in the pane's directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run
<serial> <line>` and `exit <serial> <N|?>` -- `exec` reads back its serial,
so follow `log` for the exit. The directory's next command reuses a finished
@@ -153,12 +182,23 @@ record written back from such a file's body goes to its REPL, as the click
would. `Repl -` unbinds; a bare `Repl` says the binding. With several
REPLs bound for a language an exec asks which, logged `ask <serial> repl a b`
(Del's side from the keyboard is `ask <serial> del k j`): answer with
-`echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing. Multi-line code
+`echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing (the
+log says `answer <serial> a|-`). Kill does not stop what a REPL runs (it was
+typed, not started by pardes): `echo 'sig INT' > $m/pane/<repl>/pty/ctl`
+interrupts it. A range of a `.py` pane goes to its REPL by writing the event
+record `MX<q0> <q1>` to that pane's `event`. A chorded exec's record (flag 8)
+is followed by two, its argument and where it came from; write all three back
+as read (one write or three) and it runs once with its argument. `Tty`'s argument is a shell
+(`Tty fish`), and `Tty` on a pane's ctl opens a new terminal pane. After a
+Restore, panes have new serials (`restored <old> <new>` in the log), a
+command pane shows `exit ?` and does not run again, and REPLs are unbound. Multi-line code
written to `pty/data` should be a bracketed paste, `\e[200~<code>\e[201~`,
then, in a separate write once the REPL has echoed the paste (Python 3.13+
-takes a `\r` read with the paste as part of it, even for one line), `\r`,
-and a second `\r` when the code's last line is indented (a `def` or `for`
-body): one Enter leaves such a block open. Sent line by line, a blank line ends a Python block, and Python
+takes a `\r` read with the paste as part of it, even for one line), `\r`.
+A paste of one line needs that one `\r`; a paste of more than one line
+needs a second `\r` unless the pasted code ends in a newline (one Enter
+leaves a multi-line input at `...`). A middle click, or an event
+write-back, sends what it needs by itself. Sent line by line, a blank line ends a Python block, and Python
3.14's REPL auto-indents each line it is typed. Every refused 9P write adds an `err <serial|->
<file>: <why>` record to `$m/log`; through a mount the write itself only says
`Invalid argument`.
@@ -172,10 +212,11 @@ Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/'
v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from
exec or a tag, not a one-line ctl write). Its changes are one undo step, and
one that fails changes nothing and fails the write with acme's words (`Edit:
-no substitution`), logged as `err`. A block goes in one write to the pane's
-`ctl`, the root `ctl` (the active pane) or `exec`: an `Edit` line takes the
-lines after it until its `{` closes or its `a`/`c`/`i` text ends with `.`.
-bash's builtin `printf` writes line by line, so use a heredoc or `env printf`:
+no substitution`), logged as `err`. A block goes to the pane's `ctl`, the
+root `ctl` (the active pane) or `exec` on one open, in one write or several
+(bash's `printf` writes line by line): an `Edit` line takes the lines after it
+until its `{` closes or its `a`/`c`/`i` text ends with `.`, and runs then. `sel`
+reads the selected text, and a write to it replaces the selection:
```sh
cat > $pane/ctl <<'END'
@@ -189,6 +230,23 @@ env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
`p` and `=` print to the directory's
`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`.
+> **sam gotchas** (as sam does them, pardes too)
+> - `$-1` is the last line when the text ends in a newline (`a\nb\n`: `b\n`),
+> but the one before it when it does not (`a\nb`: `$` is on `b`, so `a\n`).
+> - With a final newline, the line after the last is the empty place at the
+> end, not an error: `N+1` at the last line is `#<len>,#<len>`. Without one
+> (`a\nb`), the last line runs to the end and `N+1` is out of range.
+> - `^` and `$` match at the text's end too: `/^/` from the end finds the
+> empty place after a final newline.
+> - `2,1` is no error: it is `#<start of 2>,#<end of 1>`, an empty range at
+> line 2's start (only a range whose end is before its start is refused).
+> - `y` yields the stretch before the first match too, empty if the text
+> starts with one: `,y/a/` on `abc` gives `` and `bc`.
+> - `c/&/` puts a literal `&`; only `s` expands `&` to the match.
+> - `line:col` columns count bytes from 1: `12:0` is refused
+> (`a column counts from 1`); a tool's character column is the same only
+> on an ASCII line.
+
| Operation | Shell | Python client |
|---|---|---|
| Read text | `cat $pane/body` | `client.read(pane + '/body')` |
@@ -199,7 +257,12 @@ env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
| Delete that range | `: > $pane/data` | truncate `data` (open with OTRUNC) |
A write leaves `addr` just past what it wrote, so a second `echo x > data`
-inserts after the first: write `addr` again before each replacement.
+inserts after the first: write `addr` again before each replacement. Each
+`data` write is one undo step; to make a loop's writes one, `echo 1 > mark`
+(an undo point now), `echo 0 > mark`, the writes, then `echo 1 > mark`.
+A `+New` scratch counts as unsaved (and holds up Exit, Restore, Del) only
+once it holds 100 bytes or more. A read
+of `data` or `xdata` moves `addr` past what it read, as in acme.
Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts).
| 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'))` |
@@ -224,7 +287,7 @@ and a search that backtracks
past a step budget (about 300 ms) fails with `regular expression search
gave up, ...`. A failed address says
why (`no match for regexp`, `address out of range`) and leaves no address:
-`data` refuses until the next good one, so a missed target is never
+`addr` reads empty and `data` refuses until the next good one, so a missed target is never
written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds
only the end of a forward search, as in acme, and reads empty until set;
truncate it to lift it. In `12:5` the column counts bytes from 1 and clamps
@@ -247,13 +310,14 @@ the address comes back to where it began, or set `limit`.
a reserved zero, the dirty flag, the width in cells, the font and the tab
width, then `current` or `notcurrent` — and takes `get` (reload from disk),
`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
-`Collapse`). A `lock` another open holds fails at once with `file in use`
+`Collapse`, `Undo`/`Redo`: 256 steps; with none left they say so, and the
+write succeeds). A `lock` another open holds fails at once with `file in use`
(EBUSY): retry it. Session builtins and settings go to the root `ctl`, which reads
back every setting in the syntax it takes. A ctl write is checked whole
first and refused as `unknown control message "X"` (EINVAL) and the like,
a required argument missing included (`wrong #args ... "Mount"`); then a line
whose builtin reports an error fails the write with that error and the line
-(EIO), after the lines before it took effect. A `Save` on a scratch fails
+(EIO, or an errno that fits: ENOENT for a missing pane, file or dump), after the lines before it took effect. A `Save` on a scratch fails
rather than prompt. The lock binds only clients that take it, and is held by the
open that wrote it, so a shell holds an fd across the edit:
`exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`.
@@ -301,15 +365,21 @@ as read acts on its text when the range is empty (the only way for a record
at `0 0`). Chord reports need explicit handling. A record is
`<origin><action><q0> <q1> <flag> <n> <text>\n` and its text may hold
newlines: read `n` bytes of text, never up to the next newline (acme counts
-runes; pardes counts bytes, as all its offsets are).
+runes; pardes counts bytes, as all its offsets are). Every address lands on a
+rune boundary, never inside one and never widened to a grapheme cluster: `#n`
+or `line:col` inside a rune snaps back to its start, a match covers the runes
+it touches, and a combining mark or a CRLF's `\r` is addressable alone.
A Restore puts a new editor under every client: the Restore write is
answered, then every connection is hung up (their fids name the old
-editor's panes); dial again, and the new log names the restored panes then
-`restore <path>` -- the authority, since a slow client may see the cut
+editor's panes); dial again, and the new log has a `new` for each restored pane, then
+`restore <path>`, then `restored <old> <new>` for each pane and
+`restoredcol <old> <new>` for each column -- `restore <path>` the authority, since a slow client may see the cut
before the answer. A 9ns older than cloud9 2a7137c could fail the Restore
write with ECONNRESET although the Restore went ahead; trust the log. `Dump` writes `pardes-<date>-<time>.zon`, the time in UTC, under
`DumpDir` (`$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) and logs
-`dump <path>`.
+`dump <path>`. A relative `Restore <path>` is looked for in `DumpDir` first,
+then in the directory pardes started in; a bare `Restore` takes the last dump
+this session wrote (none yet: refused, name one).
Read the event implementation before building an interceptor. Close handles in
`finally`, and disconnect after a socket timeout. The service shares sixteen
connection slots (the next client's version gets `too many connections`)
@@ -317,8 +387,9 @@ 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.
+Stat sizes are real: for `event` and `pty/data` the length of the record a read
+would answer (zero when nothing is waiting), for `log` the whole ring, the text
+an open would freeze now.
## Terminal input and screen observations
diff --git a/build.zig b/build.zig
index d4f3e120..34acc007 100644
--- a/build.zig
+++ b/build.zig
@@ -506,7 +506,24 @@ pub fn build(b: *std.Build) void {
\\
;
if (std.mem.count(u8, budgeted, group_anchor) != 1) @panic("mvzr's matchGroup changed: redo its group backtracking in build.zig");
- const patched = std.mem.replaceOwned(u8, b.allocator, budgeted, group_anchor, group_backoff) catch @panic("OOM");
+ const backed = std.mem.replaceOwned(u8, b.allocator, budgeted, group_anchor, group_backoff) catch @panic("OOM");
+ // `$` is refused anywhere but last, so `h$|zz` was a bad pattern
+ // while `zz|h$` was fine. An alternative or a group ends at `|` and
+ // `)`, and the matcher's `.end` ends the alternative it is in, so
+ // `$` may stand before either (docs/mvzr-group-backtrack.md).
+ const end_anchor =
+ \\ '$' => {
+ \\ if (i + 1 < in.len) {
+ \\
+ ;
+ const end_before_alt =
+ \\ '$' => {
+ \\ // pardes's patch (its build.zig): `$` may end an alternative.
+ \\ if (i + 1 < in.len and in[i + 1] != '|' and in[i + 1] != ')') {
+ \\
+ ;
+ if (std.mem.count(u8, backed, end_anchor) != 1) @panic("mvzr's parser changed: redo its `$` before `|` in build.zig");
+ const patched = std.mem.replaceOwned(u8, b.allocator, backed, end_anchor, end_before_alt) catch @panic("OOM");
break :mvzr b.createModule(.{ .root_source_file = b.addWriteFiles().add("mvzr.zig", patched), .target = target, .optimize = optimize });
};
diff --git a/docs/config.md b/docs/config.md
index 29671e4e..96281cd5 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -46,7 +46,14 @@ the browser still applies the compiled percentage to its DOM glyphs.
The startup path is printed whether or not a file exists — that is usually when
it is most useful — and is ordinary selectable text, so a right click on it
opens the file. Shell follows the same requested/effective/pending model as
-Font. `Compiled default shell` is the command built into the binary; `Shell
+Font. `Default shell` is the one used while no `Shell` is set: `$SHELL`, the
+user's login shell, else `/bin/sh` (also when `$SHELL` names nothing
+executable); an explicit `Shell` overrides it. `Shell <name or path>` is
+refused unless it names an executable file (`Shell: no shell "x" (...)`, or
+`Shell: not a shell: /etc is a directory`; a bare name is looked for in the
+usual bin directories), as `Tty <shell>` is, and a
+bare `Shell` goes back to the default. The root ctl reads `Shell <the one the
+next terminal runs>`. `Shell
effective (last spawn)` is the executable the native host really chose after
installation lookup and fallback. A changed request remains pending until a
terminal is spawned, because the core does not resolve native executables.
diff --git a/docs/fs.md b/docs/fs.md
index d2f35505..6b393918 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -32,7 +32,10 @@ and `/virtual/X` both name the served `/X`.
`--mount=peer=work` mounts the named session `work`; the dial can also be an
absolute socket path, `unix!/path`, `tcp!IP!port`, or `quic!IP!port`.
At runtime, use `Mount peer dial` and
-`Unmount peer`. There are eight named mounts; `os` and `self` are reserved.
+`Unmount peer`. Mount dials the peer when it mounts it and fails its write
+if nothing answers, `Mount peer /tmp/s: dial failed: no answer` (or `timed
+out`, `hung up`), mounting nothing; a peer that goes away later is found out
+by the next use, as `look: /n/peer/f: dial failed: no answer`. There are eight named mounts; `os` and `self` are reserved.
Unmount refuses mounts still used by a pane, its working directory, or a
pending Save. Mounts are saved in dumps. Save uses the file's original mount.
@@ -71,7 +74,11 @@ for any process, and `$NINE_MOUNT/pardes/NAME/` a `--detach=NAME` session's.
9ns exports `$NINE_MOUNT` to everything it starts, so a script checks that
variable to know the mount is there, and takes the name from `$PARDES_9P`
(`pardes-9p-<pid or NAME>.sock`). A new pane made through `pane/new` is a scratch named
-`<dir>/+New` until it is given a name. A column may hold no pane, as in acme:
+`<dir>/+New` until it is given a name, where `<dir>` is the session's
+directory (the one pardes started in), whichever pane last had the keyboard:
+no pane asked for it, as acme's new window has acme's directory. So is a
+`New` written to a column's `exec` or to `/tagexec`, a word in a tag no
+pane owns. A column may hold no pane, as in acme:
`Newcol` makes one empty, and closing a column's last pane leaves it empty
with its tag holding the keyboard (`focus` reads empty) and logs only the
`del`. `pane/new` places its pane as acme's makenewwindow(nil) does: in the
@@ -104,22 +111,42 @@ Existing Plan9port/v9fs clients need a userspace bridge for QUIC.
/listeners the session's dial addresses
/focus the serial of the pane with the keyboard; write a serial to give it the keyboard,
which makes its column the active one as a click there would
-/ctl the settings, one a line as a write takes them; write a setting or a session builtin
-/commands every builtin: word, `arg` if it takes one, `root` or `pane` (the ctl that takes it), a setting's values
-/layout one line per column, left to right: serial index x width current|notcurrent
- empty|full pane-serials...; then active <serial>: the active column, where
+/ctl the settings, one a line as a write takes them; write a setting or a session builtin;
+ `size <cols> <rows>` sets the screen of a session no frontend is attached to
+ (`--detach`, 160x50 until then; refused while a frontend owns the size),
+ from 20x6 to 4096x4096 (outside that, `invalid size`, EINVAL), and refused
+ when a column has not the rows for its panes' minima, each its tag and 2
+ rows, the minimum placement keeps; so a size once taken is taken again,
+ and growing is never refused. A pane the resize took under its minimum
+ gets its rows back from its column's others; a terminal's pty follows its
+ pane on every resize
+/commands every builtin: word, `arg` if it takes one, `root`, `pane` or `both` (the ctl that takes it:
+ Edit is both, at the active pane from the root; a pane's word such as Undo or Msg
+ is refused at the root), a setting's values, then ` -- ` and what it does
+/layout one line per column (16 at most; the board 6; Newcol past that fails,
+ `no space for a column: 16 max`, ENOSPC), left to right: serial index x width current|notcurrent
+ (the column with the keyboard now) empty|full pane-serials...; then active
+ <serial>: acme's activecol, which the keyboard leaving for another column's
+ tag does not move, so the two can differ -- the active column, where
pane/new and a look place a pane next (- when there is none)
/tag the workspace tag; > replaces it, >> appends, one line
+/tagexec write a word: a middle click on it in the workspace tag; read as /exec
/col/<n>/tag the tag of the column with serial n, the same way
/col/<n>/ctl write Delcol, Joincol, New or Tty: each acts on that column, as from its tag
-/col/<n>/exec write a word: a middle click on it in that column's tag; rmdir col/<n> closes
+/col/<n>/exec write a word: a middle click on it in that column's tag; read as /exec; rmdir col/<n> closes
an empty column (a column with panes is refused, ENOTEMPTY)
-/pane/new open it to make a pane; the read answers that pane's serial. A session holds
+/pane/new open it to make a pane (the bottom half of the active column's last
+ pane, acme's coladd; with no room there, last all the same, taking half
+ the tallest pane's rows); the read answers that pane's serial. A session holds
64 panes (16 on the board); at that, every route that would open one -- this
- open, look, exec, New, Tty -- fails with `no pane slots: 64 max` and an err
- record, and look reads back empty
+ open, look, exec, New, Tty -- fails with `no space for a pane: 64 max` (ENOSPC through 9ns, which has
+ no word for ENFILE) and an err
+ record, and look reads back empty; a column with no room for one
+ (each pane keeps its tag and 2 rows) refuses it the same way,
+ `no space for a pane in that column` (docs/tags.md)
/pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll
- errors event look exec, plus pty/{ctl,status,data} on terminals
+ errors event look exec tagexec (a word as a click in its tag), plus
+ pty/{ctl,status,data} on terminals
/os/ the host filesystem
/src/ the editor's embedded sources, only when built with -Dembed-sources=true
```
@@ -136,7 +163,8 @@ gives each column's serial and its index left to right, and the log says
`newcol <serial>` and `delcol <serial>` as columns come and go, and after a
Restore `restoredcol <old> <new>` for each column as `restored` does for
panes. Joincol folds a column into the one on its right, which keeps its own
-serial and tag; the joined one's serial is gone (`delcol`). The root ctl
+serial and tag; the joined column's panes go below that column's own, in
+their order, and its serial is gone (`delcol`). The root ctl
takes no column word: its `Delcol` is refused, pointing at
`col/<serial>/ctl`.
@@ -146,12 +174,20 @@ Each builtin declares its scope in src/builtins.zig (`scope = .session`;
every setting is one, the rest act on a pane). `/ctl` takes the session's
builtins, one a line, at whichever pane has the keyboard as each runs --
`Newcol`, `Dump`, `Mount name dial`, `Theme ink`, `Verbose off`; `Exit`,
-which quits the editor as acme's does (it refuses once, naming each pane with
-unsaved text, a `+New` scratch of 100 bytes or more too, in one line,
-`<name>, <name>: Modified (Exit again to discard)`, and a second `Exit`
-with nothing edited since quits, throwing that text away; a scratch or a
+which quits the editor as acme's does (it refuses once over the panes with
+unsaved text, a `+New` scratch of 100 bytes or more too, whether it came
+through a `ctl` write or a click: first one `/log` record per pane,
+`unsaved <serial> <name>`, then the write fails with one line that is
+never a list cut short, `<name>: Modified (Exit again to discard)` for one
+pane, `4 unsaved panes: Modified (Exit again to discard)` for more, and
+the `err` record says the same; each pane's message row names it. Restore,
+Del and Delcol refuse the same way, with their own word; an `Exit` after more editing refuses
+again naming only the panes edited since the last refusal, as acme's does,
+and an `Exit` with nothing edited since quits, throwing all of it away; a scratch or a
command's output under 100 bytes is not asked about, as acme's winclean
-asks about no small unnamed window; `Restore`, which replaces every pane,
+asks about no small unnamed window (so a scratch's `dirty` of 1 in `/index`
+blocks nothing until it holds 100 bytes: it has no file to be out of step
+with, and a few lines typed to try something are not work to lose); `Restore`, which replaces every pane,
asks the same first -- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in
`DumpDir` (default `$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`)
and logs `dump <path>`, and `Restore` with no path takes the last one; a
@@ -163,7 +199,11 @@ before the answer was read; the log is the authority): dial again -- a 9ns
mount is one such connection, so after a Restore stop it and start 9ns
again, or every file under it fails -- and the new
log names the restored panes and
-`restore <path>`. The answer has 200 ms to leave before the cut, so a slow
+`restore <path>`. Restored panes have new serials (`restored <old>
+<new>` maps them) and so may columns (`restoredcol`; a fresh editor counts
+column serials from 1 again, so they often come back the same). A command
+pane comes back showing what it showed, its tag `exit ?`: its command is
+not run again. REPL bindings are not dumped. The answer has 200 ms to leave before the cut, so a slow
client may see only the cut; the log's `restore <path>` is what says the
Restore happened. Keeping connections across it would mean carrying serials
and opens into the new editor, which acme, whose Load only adds windows,
@@ -171,8 +211,10 @@ never needed); and `Kill`, which
does not quit but stops commands, as acme's does: bare, every command pardes
started, and `Kill make ls`, those whose line begins with one of the words. A
command pardes started is a command pane's, until its child exits -- Kill
-sends SIGTERM to its running job and to its shell, so the whole line stops
-(of `sleep 30; echo done` the `echo` never runs) -- or a line it typed into
+sends SIGTERM to its running job, to its shell and to every `&` job the
+line started, so the whole command stops and leaves nothing running (of
+`sleep 30; echo done` the `echo` never runs; an `&` job outlives only a
+command that exits on its own) -- or a line it typed into
a terminal (a
word written to `exec`, a middle click on one, a `pty/run`), from its shell's
start mark (C) to its end mark (D), where Kill sends its foreground job SIGTERM -- acme posts the
@@ -182,9 +224,10 @@ shell's group, so there is none to signal: Kill says `Kill: no job to
signal`, and a write of it to `ctl` fails with that; with nothing running
it says `Kill: nothing running`; and, of a typed line, only the
foreground job, after which what the rest of the line does is the shell's
-affair: of `sleep 30; echo done` typed at a prompt, fish goes on and runs
-the `echo`, and bash abandons the line, as each does when a job it waits
-on is killed --
+affair: of `sleep 30; echo done` typed at a prompt, bash (saying
+`Terminated`) and fish (saying `Job 1, 'sleep 30' terminated by signal
+SIGTERM`) both go on and run the `echo`: SIGTERM, unlike an interrupt,
+ends only the job, not the line --
and reads every setting there is, one a line, in the words a write of it
takes (`Verbose on`, `WindowOpacity 70`, `PanelSlide off`, `DumpDir
<the directory in effect>`, `LocationsConfig ...`), so writing what it reads
@@ -193,7 +236,9 @@ back changes nothing; a setting the frontend cannot show (`Lift`,
Config window's, not settings. A pane's `ctl` takes the builtins that act on
a pane (`Del`, or `Del k`/`Del j` to give its rows to the pane above or
below, `Save f`, `Collapse`, which folds that pane, `Undo` and
-`Redo`, which step its body through its edits, `Find pat`)
+`Redo`, which step its body through its last 256 edits -- with none left
+they say `Undo: nothing to undo` and the write still succeeds, as acme's
+Undo is silent --, `Find pat`)
beside acme's `get`, `lock` and `unlock`. The column words are pane words
too, acting on the column that pane is in: `Delcol`, `DelAbove`, `DelBelow`
and the focus moves `Left`/`Right`/`Up`/`Down` from it. `Joincol` and
@@ -215,7 +260,7 @@ and `not a session control message "X": write it to pane/<n>/ctl` or `not
a window control message "X": write it to /ctl` for a word of the other ctl. 9ns maps them all to EINVAL, and a write
refused here has done nothing. A line that then fails as it runs fails the
write with the error the editor reports for it and the line, e.g. `Mount:
-AlreadyMounted "Mount peer /tmp/s"` (EIO), and `control message needs its
+already mounted "Mount peer /tmp/s"` (EIO), and `control message needs its
argument "Save"` (EINVAL), for a builtin that would have asked at a prompt
(a `Save` on a scratch) rather than open one nobody is there to answer. The
lines before a failing one have taken effect and those after it never run,
@@ -227,10 +272,19 @@ performed what it asked for (a save written, a shell started). A click on
the same word, or the word written to `exec`, still opens its prompt.
`/commands` lists every builtin the registry holds, in registry order, one
-a line: its word, `arg` when it takes one, `root` or `pane` for the ctl
-that takes it, and for a setting that chooses among words those words,
-comma-joined, e.g. `Newcol root`, `Save arg pane`, `Verbose arg root on,off`,
-`Placement arg root acme,pardes`. Such a setting written bare steps to its
+a line: its word, `arg` when it takes one, `root`, `pane` or `both` for the
+ctl that takes it, for a setting that chooses among words those words,
+comma-joined, then ` -- ` and one sentence of what it does (a builtin's doc
+comment's first sentence, a setting's doc), e.g.
+
+```
+Newcol root -- An empty column right of the keyboard's, its tag taking the keyboard.
+Save arg pane -- Write the pane's text to its file, or to the file its argument names.
+Verbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.
+Placement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.
+```
+
+Every builtin has its sentence; a test fails the build of one without. Such a setting written bare steps to its
next value, so a two-valued one flips (`Crt`, `Placement`, `BootShell`), as
its word clicked in a tag does; a value it does not take is refused with
`bad value in control message; takes ...` naming those it does. It is
@@ -288,16 +342,32 @@ same tree without leaving the process.
`/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.
+ `file:12` jumps to a line, a directory opens a terminal there that runs
+ `ls` once (pardes has no directory listing pane of acme's kind; a shell
+ in it is where one goes on from a directory), a URL opens in the
+ browser, and a plain word, as acme's look3 does, selects its next
+ place in that pane after the dot, wrapping at the end, opening nothing
+ (`LookWord list` on the root ctl lists every place in a `+Search` pane
+ instead, as pardes did before; `LookWord search` is the default). In a
+ terminal, which has no dot to go on from, a plain word is always listed
+ in a `+Search` pane, its rows naming the terminal as
+ `@p<serial>:<line>:<cols>`, the columns a range, `@p3:12:5-9` (bytes 5
+ through 9 of logical line 12, 1-based, both ends in), which a look of the
+ row selects,
+ and look reads that pane back.
- 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`, ...),
+ `Unmount NAME`, `Dump`, `Restore`, `Msg TEXT`, `Find`, `Grep`, `Tty`, ...;
+ `Tty`'s argument is the shell it runs, `Tty fish`, and `Tty` on a pane's
+ ctl opens a new terminal pane, not in that one: in the active column, as
+ acme's makenewwindow puts a new window (util.c:456-467: the active column
+ first, then the pane's own), under the text with room or halving the
+ tallest, never shorter than its tag and 2 rows),
or anything else, a command line. Written at a terminal at its prompt it
is typed into that shell (and a terminal whose shell exits, `exit` typed
or run, closes its pane). From anywhere else -- a file, a scratch, a tag,
a terminal whose tty a program holds -- it runs as a command pane: a
- terminal whose child is the root ctl's `Shell` (fish unless set) run
+ terminal whose child is the root ctl's `Shell` ($SHELL, else /bin/sh, unless set) run
with `-c` and the line, in the pane's directory, with job control on
(bash, sh, dash, zsh, ksh `-m`; fish `status job-control full`),
which shows its output and then `exit N` (its tag reads `<dir> (<line>)
@@ -305,7 +375,7 @@ same tree without leaving the process.
exits, as in acme, not when its terminal closes: a job it left in the
background prints on below `exit N` until it lets go of the pty, and a
command that lets go of its terminal early runs on to its own exit. A
- background job outlives the command: job control gives it a process
+ background job outlives a command that exits on its own (Kill stops it too): job control gives it a process
group of its own, so the hangup the kernel sends the terminal's
foreground group when the shell exits misses it. It survives the pane
closing too, but its writes to the terminal then fail, so start one that
@@ -317,7 +387,10 @@ same tree without leaving the process.
the screen and its scrollback (`clear`, ED3) erases the history above it. A command pane's own exec starts
the next command there too. The log says `run <serial> <line>` and `exit
<serial> <N|?>`; `exec` reads back the command pane's serial; Kill stops
- its whole line; a line is at most 1 KB. To run a command again, execute
+ its whole line, `&` jobs included; a command line is at most 1024 bytes, and a
+ longer one written to an exec fails the write (EINVAL, `a command line is
+ at most 1024 bytes`) before anything in it runs; a builtin's line (a long
+ `Msg`, an Edit block) may be longer. To run a command again, execute
its line again from its directory: `echo 'make test' > pane/<n>/exec` on
the command pane runs it there, below the last run. A misspelled word is a
command that says so and ends `exit 127`. `echo Tty > pane/<n>/ctl` makes
@@ -354,24 +427,63 @@ is no gesture and is never sent: a script writes to the REPL pane's
`pty/data`, multi-line code as a bracketed paste (`\e[200~<code>\e[201~`,
then `\r` in a write of its own once the REPL has echoed the paste --
Python 3.13's REPL takes an Enter read with the paste as part of it, even a
-one-line one -- and a second `\r` when its last line is indented: one Enter
-leaves a block open, pasted or typed; a middle click does all of this
+one-line one -- and, for a paste of more than one line that does not end
+in a newline, a second `\r`: one Enter leaves a multi-line input at `...`; a middle click does all of this
itself, holding the Enter until the REPL answers the paste), since line by line a blank line ends a Python block and Python
3.14's REPL auto-indents each line typed into it. The REPL gets the text
-wherever it is -- at a `pdb` or `input()` prompt too. Bindings are not dumped, so a Restore leaves none.
+wherever it is -- at a `pdb` or `input()` prompt too. Over 9P, a range of a
+`.py` pane goes to its bound REPL as a click would: write the event record
+`MX<q0> <q1>` back to the `.py` pane's `event` (a range past its end is
+refused, `range past end of body`). `Kill` does not stop
+what a REPL runs, since pardes did not start it; `sig INT` on the REPL
+pane's `pty/ctl` interrupts it as Ctrl-C would. Bindings are not dumped, so a Restore leaves none.
The root's pair clicks at the active pane and `/pane/<n>/look` and
-`/pane/<n>/exec` at that pane. Blank lines are skipped, and every other line
+`/pane/<n>/exec` at that pane; `/tagexec` and `/col/<n>/exec` click in the
+workspace's or that column's tag (never an event reader's, which hears
+only its pane's; a command they run starts in the session's directory,
+where pardes started, not the focused pane's), and what the word says is logged as the session's,
+`msg -`. 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
+write with EINVAL; a builtin that fails there fails the write as well
+(below: one rule). 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; a look that found text answers the pane the text is selected in,
not the hits buffer it opened.
-A look takes acme's addresses after a colon (editors/acme/look.c:450): a
-line written to `look` as `file:/re/`, `file:#n`, `file:$` or any address
+Reads are a stream: once an open has read the answer, a second read on it
+gives EOF (on an open that wrote, until its next write); open it again, or
+seek to 0, to read it again. The answer belongs to the open, as
+/net/tcp/clone's does: an open that
+wrote reads what its own last write touched, from the start after each
+write, whatever offset the read comes at (a shell's `exec 3<>look` shares
+one offset between its write and its read, as with `pty/run`); an open
+that never wrote reads the session's last answer as it stood when it was
+opened. So `echo x > look; cat look` works for one client, but two
+clients doing it at once may read each other's; for that, write and read
+on one open: `exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.
+
+A write of command lines -- to `look`, `exec`, `tagexec`, a `ctl` of the
+root, a pane or a column, or a column's `exec` -- runs each line once it is
+whole: a mount cuts a big write at its message size (4 KiB through the
+kernel's, 8 KiB from a client that asks), anywhere, and each piece comes as
+a write of its own, so the open keeps a last line with no newline yet, or
+an Edit block whose text has not ended, until its next write, and runs it
+then; what is left when it closes runs at the close, where an Edit block
+whose `{` or `a`/`c`/`i` text never ended fails and changes nothing
+(``unmatched `{'``, or `a, c or i text not ended by a . line`, logged as an
+`err`): sam takes the end of input for a `.`, but a block that reaches Edit
+unfinished was cut short. A fragment never runs on its
+own. A line or Edit block held past 1 MiB is refused (EINVAL).
+
+`@p<serial>:<address>` addresses the pane with that serial as `file:<address>`
+does a file, with any sam address (`/re/`, `?re?`, `#n`, `$`, `0/re/`, a range),
+a terminal too: its body is then the text of its logical lines (the lines
+a `+Search` of it lists), addressed from its cursor, and the match is
+selected. A miss, and a serial no pane has (with a line, `@p77:3`, as
+with any other address), each log an `err`, and look reads back empty. A look takes acme's addresses after a colon (editors/acme/look.c:450): a
+line written to `look` as `file:/re/`, `file:?re?`, `file:#n`, `file:$` or any address
opens (or finds) the file and selects what the address names. **The
address is evaluated from the file's dot**, as acme's is: `file:/re/`
finds the next match after the current selection, not the first in the
@@ -387,7 +499,10 @@ selection, and leaves `look` reading back empty.
`/pane/<n>/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. `body` appends on write and replaces on truncating open. A
+directory. The write is the name and its newline, nothing trimmed: a blank
+inside a name is taken (`two words.zig`), but one at either end, or a
+control character, is refused, `bad character in file name` (EINVAL), as
+acme refuses a blank (xfid.c:650), rather than quietly cut off. `body` appends on write and replaces on truncating open. A
terminal's `body` is its history as plain text, frozen per open, in logical
lines: a row the terminal wrapped is joined back to the row before it (the
wrap is ghostty's, as `pty/run`'s output unwraps), and the last line ends
@@ -414,7 +529,13 @@ tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`):
whether the pane has the keyboard. It takes the pane's builtins (below),
`get`, which reloads the buffer from the name it
carries (unsaved edits are refused once, `<name>: Modified (get again to
-discard)`, as acme's get asks winclean, exec.c:513), `answer <choice>`
+discard)`, as acme's get asks winclean, exec.c:513). A file that changes on
+disk reloads by itself only into a buffer with no unsaved edits; one with
+them keeps its text and stays dirty, says `<name> changed on disk (get
+reloads it, Save overwrites it)` and logs `changed <serial>`, and then its
+`Save` warns once, `<name> modified on disk since read (Save again to
+overwrite)`, as acme's Put does (exec.c:577) -- acme reloads nothing by
+itself. `answer <choice>`
for the question the pane asks on its notice band, which the log names as
`ask <serial> <what> <choices>` -- `ask 4 del k j` for Del's side from the
keyboard (`k` the pane above takes the rows, `j` the one below), `ask 4
@@ -444,8 +565,10 @@ address expression (`#0,#5`, `/pattern/`, `2+1`, and pardes's own `12:5`,
below); `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 only the end of a forward
-search, as acme's does, and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it, and
-truncating `addr` leaves it as it is (below).
+search, as acme's does, and reads empty until it is set. Truncating `dot` empties it, truncating `limit` lifts it --
+though a write after the truncation that fails puts the old limit back, so
+`echo /bad/ > limit` changes nothing -- and truncating `addr` leaves it as
+it is (below).
A rename everywhere, or any other sam edit, is one write to the pane's
`ctl`: `Edit ,x/foo/c/bar/` runs acme's Edit (docs/tags.md) on the body as
@@ -454,15 +577,24 @@ nothing. A write is one message a line, except that an `Edit` line takes
the lines after it while its `{` group is open or its `a`, `c` or `i` text
block waits for its `.` line, on a pane's `ctl`, the root's (at the active
pane) and `exec` alike; an unclosed group is refused (``unmatched `{'``).
-bash's builtin `printf` writes a line at a time, so write a block with a
-heredoc or `env printf`.
+A block may come in several writes on one open (bash's builtin `printf`
+writes a line at a time): the open holds it until it ends (below).
+
+A line number counts newlines as sam's lineaddr does (editors/sam/
+address.c:180), so the empty line just past a text's last newline is an
+address: `1` of an empty text is `#0,#0`, `2` of `a\n` is `#2,#2`, and
+`Edit 1i/header/` on an empty file inserts; a line past that is `address
+out of range`.
`line:col` is a pardes extension to sam's addresses, the spelling Look
takes in `file:12:5`: `12:5` is the point at line 12, column 5, and it
composes like any simple address (`12:5,14:1`, `12:5+#3`). The column is
in bytes from 1, as Look's is, clamped to the end of the line and snapped
-back to the start of the character it falls in; a line past the end, or
-column 0, is `address out of range`. sam would read `12:5` as a syntax
+back to the start of the rune it falls in; a line past the end, or
+column 0, is `address out of range`. Since the column counts bytes, a
+column a tool gives in characters (pytest's, a compiler's) is the same
+only on an ASCII line; elsewhere address the line and search it
+(`12/name/`) or use `#n`. sam would read `12:5` as a syntax
error.
Truncating `data` or `xdata` deletes the range `addr` names and nothing
else, so a shell's `echo NEW > data` replaces that range, `: > data`
@@ -471,7 +603,8 @@ whole buffer. Truncating `data` is pardes's own: acme ignores OTRUNC there
(editors/acme/fsys.c:543) and every write inserts. And as in acme a write
leaves `addr` just past what it wrote, so a second `echo x > data` deletes
the empty range there and inserts after the first rather than replacing it
-again; write `addr` before each replacement.
+again; write `addr` before each replacement. A read of `data` or `xdata`
+moves `addr` past what it read, as acme's does.
`addr` belongs to the pane rather than to a client and keeps what was written
until someone writes another, so writing an address and reading it back
evaluates it, which is what acme(4) promises of its own `addr`. Unlike acme,
@@ -529,15 +662,31 @@ expression`, `regular expression search gave up, ...`, or sam's
`addresses out of order` for a range that ends before it starts
(`#100,#50`), which acme lets through. A failed write to `addr` leaves no
address at all, where acme
-keeps the old one: until a good address is written,
-reading `addr`, and reading, writing or truncating `data` and `xdata`, fail
-with `no address: the last one written to addr failed`, so a script that
+keeps the old one: until a good address is written, `addr` reads empty
+(as an unset `limit` does), and reading, writing or truncating `data` and
+`xdata` fail with `no address: the last one written to addr failed`, so a script that
missed its target cannot then write at the last one.
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.
+pane. Every `data`, `xdata` or `body` write is an undo step of its own
+while `mark` is 1; to make a loop's writes one step, write `1` (an undo
+point here), then `0`, the writes, then `1` again.
+
+One rule for what fails: a write fails whenever what it asked for fails,
+whether it came to a `ctl` (the root's, a pane's, a column's), `look`,
+`exec`, `tagexec` or a column's `exec`, or to any other file -- with an
+errno that fits, EINVAL for malformed input (an unknown word, a control
+character, a command line over 1024 bytes, a `size` or `winsize` out of
+range, a bad address or event record), else EIO or the errno the words
+name (ENOENT for a pane or file gone, ENOSPC for no room or slot, EBUSY
+for a held lock) -- and logs its reason exactly once, as `err <serial|->
+<file>: <why>`, with no `msg` for it. A builtin a click runs (Save, get's
+`Modified`, Tty with no room, Edit) is no exception. What is not a
+failure: a look that finds nothing answers nothing and logs one `err`
+(`look: no match for ...`), the write succeeding; and a command line run in
+a command pane ends in its own time, told by its `exit` record.
`tag` reads the whole tag as the pane shows it: the computed path or PDF
page (no mark for unsaved text: the grip shows that, and `dirty` says it),
@@ -570,26 +719,54 @@ an open renders its frame; stat the entry.
`/log` is one ring (64 KiB) that records whether or not anyone reads it:
`new`, `del`, `rename` (a terminal's too, as its shell changes directory --
a new terminal is `new N /` until its shell says where it is, then renamed;
-it runs `ls` once at its first prompt, a greeting that shows the directory --
+it runs `ls` once at its first prompt, a greeting that shows the directory
+it opened in, since a terminal is named by its directory and nothing else
+on it says where it is --
since a terminal is named by its directory), `exit <serial> <N>` before
the `del` of a terminal whose shell exited by itself, `ask <serial> <what>
-<choices>` when a pane asks a question (answered by `answer` on its ctl),
-and `save <serial> <name>`,
+<choices>` when a pane asks a question (answered by `answer` on its ctl;
+`ask <serial> save path` when a Save made over 9P, on a terminal or a
+scratch, opens its prompt for a path, answered `answer <path>` or `answer
+-`; asked again, the one open stands, not a second),
+`answer <serial> <choice|->` when it is answered, by key or ctl, `-` for
+taken back or for its pane closing with the question standing,
+`changed <serial>` when a pane's file changed on disk under its unsaved
+edits (below), `changed <serial> reloaded` when a pane with none reloaded
+it, and `changed <serial> deleted` when it was deleted or moved away, `unsaved <serial> <name>` for each pane an Exit, Restore,
+Del or Delcol refuses over (before that write's `err`), and `save <serial> <name>`,
`dump <path>` when a Dump is written and `restore <path>` in a Restore's
-new log after its panes' `new`s, then `restored <old> <new>` for each pane,
+new log after its panes' `new`s (a relative Restore path is looked for in
+`DumpDir`, then in the directory pardes started in; a bare Restore takes the
+last dump this session wrote), then `restored <old> <new>` for each pane,
mapping the serial it had to the one it has now, and `restoredcol <old>
<new>` for each column, and `msg <serial|-> <text>`
-for every line the editor says, repeats included (with `verbose` on, that
-includes each builtin announcing itself as it runs, on purpose: the log says
-which ran; its serial is the pane it ran at, `-` when the keyboard was on a
-column or workspace tag), and `err <serial|->
-<file>: <why>` for every write or truncation the tree refused or that
+for every line the editor says (with `verbose` on, that includes each
+builtin announcing itself as it runs, on purpose: the log says which ran --
+unless the builtin then says something of its own that starts with its
+name, `Kill: nothing running`, which takes the announcement's place; an
+announcement is logged only for a builtin that succeeds: one that fails,
+as it runs or when the host could not do its work (a Save's write, a
+Dump's), leaves none, from a click at the keyboard too. A builtin that
+fails its write (`ctl`, `exec`, `/tagexec` or a column's `exec`, and an
+open of `pane/new`) is logged by that write's `err` alone, no announcement
+and no `msg`, so the same failure again is the same record again; a line said again word for word is counted, `msg 3 Undo: nothing to
+undo (x40)`, as `err` is (below); a text past 256 bytes is cut there,
+between words, and ends in an ellipsis, `…`, as an `err`'s reason is past
+200; its serial is the pane it ran at, `-` when the keyboard was on a
+column or workspace tag, or the line came to the root's ctl, `/tagexec` or
+a column's ctl or exec), and `err <serial|->
+<file>: <why>` (the serial is that of the pane whose file it is, and for
+the root's `exec` and `look` the pane the line ran at, `err 3 exec: ...`;
+`-` for another root file) for every write or truncation the tree refused or that
failed -- through a mount a shell sees only the errno its kernel mapped the
reply to, usually `Invalid argument`, and this is the reason (`err 3 addr:
-no match for regexp`). The same err again, before any follower has read
-the first, is that record counted (`err 3 addr: no match for regexp (x4)`),
-so a client retrying a failing write does not push the rest out of the
-ring; a follower that read it gets each repeat. Through a kernel mount a client sees only an errno, which 9ns reads from the
+no match for regexp`). A record said again word for word, straight after
+itself, is counted rather than repeated (`err 3 addr: no match for regexp
+(x4)`: four in all, counting the first), so a client retrying a failing
+write does not push the rest out of the ring. A record a follower has
+already read is never rewritten: the next repeat is a line of its own
+carrying the running total, `(x5)`, and counting goes on from there, so a
+follower sees each count as a new line. Through a kernel mount a client sees only an errno, which 9ns reads from the
error's words (cloud9's 9ns/src/nine.zig, `enameToErrno`): a malformed write
-- an unknown or ill-formed control message, `bad address syntax`, `bad
regular expression` -- is EINVAL; a lock another open holds, EBUSY; a pane
@@ -608,23 +785,51 @@ files, the editor's own lines), `K` (the keyboard) or `M` (the mouse); the
action's case says where: `x`/`l` a click executed or looked at in the tag
(its offsets count the tag's whole text, path included, as `tag` reads),
`X`/`L` in the body, `I`/`D` text put in or taken out of the body, `i`/`d`
-of the tag. Read `n` bytes of the text, not up to a newline -- bytes here, where acme counts runes. Every offset and
+of the tag. The flag is acme's: 1 the text's first word is a builtin's, 2
+an expansion record follows (acme's look.c:42-43, exec.c:154-157; pardes
+never sends one: a click's record already carries the word it took, its
+range and text, so 2 is never set, whatever the origin), 4 (a look) the
+text is a file name or address, 8 (an exec) chorded: two records
+follow, the argument's text and where it came from, `<file>:#q0,#q1`, each
+with no place of its own: offsets `0 0` and flag 0, `Mx0 0 0 5 hello` (as
+acme's exec.c:182 writes them).
+Written back, an `X`/`x` record executes and an `L`/`l` record looks, as
+the click would have. A chorded one (flag 8) runs with its argument: the
+record after it in the same write, else the one kept from the click; its
+two follow-up records written back after it, together or in writes of
+their own, are consumed as its, never run as commands (written back alone
+with no argument kept, it waits for its argument record); the origin letter
+written back is ignored but for `F` on `X`, which runs as the command it
+was rather than going to a bound REPL; `I`, `D`, `i` and `d` are reports and
+are refused. Read `n` bytes of the text, not up to a newline -- bytes here, where acme counts runes. Every offset and
count pardes serves is in bytes, `#n` and `q0`/`q1` too; the event count
follows them rather than switch alone, so an acme library reads pardes
-correctly for ASCII text and not beyond it. A `#n` that falls inside a
-character, a multibyte rune or a grapheme cluster, snaps back to where that
-character starts, and `addr` reads back the snapped offset. A click in a
+correctly for ASCII text and not beyond it. Offsets are bytes, but every
+address lands on a rune boundary, as sam's and acme's work in runes, never
+inside a multibyte rune and never widened to a grapheme cluster: a `#n`
+inside a rune snaps back to its start, a `line:col` likewise, a search's
+match covers the runes it touches, and a copy of addr to dot keeps its
+runes, so a lone combining mark or the `\r` of a CRLF is addressable on its
+own (an Edit's `x`, `y` and `s` match the same way: `.` is one rune); `addr` reads back the snapped offset. (How the terminal draws such a
+text, a cluster to a cell, is apart from this.) A click in a
tag gives offsets into the whole tag as `tag` reads it, the path first.) An
open freezes the ring's text the way `/screen` freezes a frame: reads walk it
and end. Writing `follow` to that same open makes reads past it wait for the
-next record, one per read; a follower the ring outran reads `lost N` first.
-Closing the open is the only way back, as with rio's `consctl`.
+next record, one per read, after first reading all that the open froze (the
+ring's whole history, up to 64 KiB); `follow new` skips that and waits for
+what comes after, as `tail -n0 -f` does. A follower the ring outran reads
+`lost N` first.
+Closing the open is the only way back, as with rio's `consctl`. A follower
+misses nothing within a session only: a Restore hangs its connection up
+(and a 9ns mount with it, which must be restarted), so it dials again and
+reads the new log from its `restore <path>`.
A read with nothing to give yet -- a following `log`, `event`, `pty/data`, a
`pty/run` before its answer -- is held, the way factotum holds its log's reads
(security/auth/factotum/log.c) and acme an event read: the core keeps it, and
whoever next has news for it (a record, output, a run's answer, the pane
-closing, which answers acme's "window shut down") answers it on the
+closing, which answers `no such pane: its window shut`, ENOENT as any
+other file of a gone pane gives, where acme says "window shut down") answers it on the
connection it came on as the turn is given up. Nothing else parked is
retried for it. The core keeps the ticket cloud9 gave the park
(`Conn.hold`) and answers only while that very park waits, so a read the
@@ -655,14 +860,16 @@ are read so that the answer costs the editor a bounded amount. `exit ?` is a
command whose end mark carried no status, which is not a success; `error out
of memory` is an answer that could not be made. The header is always the
whole first line. It reads
-`busy` at once when a command is running or text is typed at the prompt
+`busy: <program> is running` at once when a command is running (bare `busy`
+where the host cannot name the program, and when text is typed at the prompt)
-- a run is a line typed at the shell's prompt, so a program holding the
terminal (a REPL, `less`) takes none: write to `pty/data` for it --
which is also when the third field of `pty/status` reads 1. `pty/status` is
one line, three right-aligned fields and a newline: the pty's columns and
rows, then busy (0 or 1). The size, and `pty/ctl`'s `winsize` read back, is
-what `winsize C R` last set, until the pane itself resizes and gives the pty
-its grid again. A line written
+what `winsize C R` last set (R of 1 is taken as 2: a one-row pty loses
+its prompt's mark and would read busy for ever), until the pane itself
+resizes and gives the pty its grid again. A line written
before a new terminal's shell has drawn its first prompt is not busy: it
waits for that prompt (a respawn meanwhile keeps it waiting for the new
shell's) and is sent then, so the first command a script gives a fresh
@@ -690,7 +897,11 @@ longer than a read comes in pieces, so a shell's `read` loop works: `exec
3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done`. bash's
`read` takes a chunk, keeps one line and seeks back to just past it; a
followed log, `event` and `pty/data` answer a read at an offset inside
-their last answer from that answer again, so no record is lost. `tail -f`
+their last answer from that answer again, so no record is lost. Through a
+FUSE mount (9ns --mntgen) bash's `read -t` does not time out on a followed
+file: the read waits in the mount, where its timer cannot cut it; wrap the
+loop in `timeout N`, or read with `cat` in the background and poll what it
+wrote. `tail -f`
never writes `follow`, so it sees nothing new: use the follow open instead. `/screen` returns JSON with
`cols`, `rows`, `cursor`, a `styles` table, and row-major `cells` of
`[grapheme, style_index]`. Each open freezes one frame until close. A
diff --git a/docs/mvzr-group-backtrack.md b/docs/mvzr-group-backtrack.md
index d074af62..867336a3 100644
--- a/docs/mvzr-group-backtrack.md
+++ b/docs/mvzr-group-backtrack.md
@@ -62,3 +62,25 @@ string end at `e`, so a group ending in either can accept a cut the full
string would not; a continuation-passing matcher (each repeat trying the
rest of the whole pattern) would be the full fix. `hasAlt` returning false
at the group's own `)` is worth fixing too.
+
+# mvzr: `$` before `|` is a bad pattern
+
+A second report, same trunk. `compile` refuses `$` anywhere but at the
+very end of the pattern, so an anchored alternative must come last:
+
+```zig
+std.debug.assert(mvzr.compile("zz|h$") != null);
+std.debug.assert(mvzr.compile("h$|zz") == null); // expected a regex
+std.debug.assert(mvzr.compile("(h$|zz)") == null); // likewise
+```
+
+The parser's check is `if (i + 1 < in.len) bad_string = true`. The
+matcher already handles `.end` inside an alternative: `matchAlt` and
+`matchGroup` hand `matchPattern` each alternative as its own slice, and
+`.end` returns an empty rest, which ends that slice. So `$` can stand
+before `|` or `)` as well as last; pardes's patch:
+
+```zig
+'$' => {
+ if (i + 1 < in.len and in[i + 1] != '|' and in[i + 1] != ')') {
+```
diff --git a/docs/tags.md b/docs/tags.md
index bfca4f0e..46c2defa 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -5,6 +5,10 @@ column, and each pane's tag. Column commands act on that column and run in
its active pane (or its first pane when focus comes from another column); a
column with no pane is described under [empty columns](#empty-columns). This makes
`New`, `Tty`, `Find`, and `Grep` available beside the work they act on.
+A command or a `Tty` run from the workspace tag or a column tag starts in
+the session's directory, where pardes was started, as acme's row and column
+tags have no directory of their own; one run from a pane's tag or text
+starts in that pane's directory.
`New` appears only in the column tag by default; pane tags keep their own
save, terminal, close, and collapse commands. `Tty` opens a new embedded terminal.
In a terminal's own tag the word names the shell it runs, `Tty+fish`, tinted
@@ -15,8 +19,16 @@ one, since other arguments (a dump's name) may contain `+`. A command pane's tag
and offers `Kill`. `Repl python` in a terminal's tag binds it as Python's
REPL, its id, `python-a`, beside the Tty word: a middle click or the execute
key on a `.py` body then types the text into it rather than running it, and
-several bound ask which. The tag's own words, `Exec <text>` by name and a
+several bound ask which. `Repl` takes any language a code fence names
+(`py`, `Python`, `sh`): ada, bash, c, c_sharp, clojure, cpp, css, elixir,
+erlang, fortran, go, haskell, html, java, javascript, json, kotlin, ocaml,
+markdown, pascal, php, powershell, python, ruby, rust, scala, typst, zig;
+an unknown one is refused, naming a few of these. The tag's own words, `Exec <text>` by name and a
command word @`cmd` still run as commands (docs/fs.md).
+A tag word runs as acme's does, in the pane's directory with no file named:
+`wc` alone waits on its stdin. pardes has no `$%` for the pane's file; name
+it (`wc notes.txt`), or select the name and middle-click `wc`, which takes a
+held selection as its argument.
Pane and column command text leave a small gap after their aligned drag grips.
GUI pane mode symbols are centered by their visible ink; changing the font or
its size refreshes the cached measurements.
@@ -230,6 +242,25 @@ Every new pane goes through one placement, chosen by the `Placement` setting:
it, in an init file, a tag or the root ctl; bare `Placement` flips it; `SPC c
p` is its leader path, and `Config` reports it.
+Under either, no placement leaves a pane, new or split, shorter than its tag
+and two body rows (acme's minht keeps one). Where the place chosen has not
+that room, the column's tallest pane is halved instead; where no pane of the
+column can give it, the new pane is refused and closed, with `no space for a
+pane in that column: each keeps its tag and 2 rows` (a 9P write or open of
+`pane/new` fails with it, ENOSPC). A pane alone in its column always fits.
+
+After placement a terminal keeps that floor too: no drag of a grip, no
+squeeze of its column by the others' weights and no smaller window takes
+it below its tag and two body rows (a one-row terminal loses its prompt's
+mark and would read busy for ever); its neighbours give the rows, and only
+a window too short for every floor leaves it less. A text pane keeps
+acme's way and can be dragged down to its tag alone; acme has no
+terminals to follow here. A folded terminal (Collapse) is a tag by choice.
+`pty/ctl`'s `winsize` likewise gives a pty two rows at least.
+A `+Errors` pane goes to any column with room, the last first; with none,
+what it would have shown (an Edit's `p` or `=`, a write to `errors`) is
+logged as `msg` records, a line each, and the Edit still succeeds.
+
`Placement acme` is acme's makenewwindow (util.c:449-495). The core keeps
acme's *active column* (activecol, dat.c:37): the column last typed in
(acme.c:487), clicked in with the select button (acme.c:659), dropped into by
@@ -251,7 +282,9 @@ never into a new column:
- `New` goes into its own column, the bottom half of its last pane
(look.c:921-923);
- a command pane or a `+Errors` pane goes to the last column, the bottom
- half of its last pane (util.c:94-98).
+ half of its last pane (util.c:94-98); a command run from a column's tag
+ goes to that column instead, and reuses a finished command pane only in
+ that column.
`Placement pardes` is what pardes did before: an empty column whose tag asked,
or has the keyboard, is filled; a scratch goes right under the pane that asked;
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 0f5e0a77..2130db27 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -255,7 +255,7 @@ pub const Listener = struct {
const refused = pardes.ctlfs.Reply.fail(req.tag, pardes.ctlfs.E.IO);
return conn.reply(&refused, "");
}
- if (!quiet and pardes.ctlfs.needsQuiet(req)) {
+ if (!quiet and pardes.ctlfs.needsQuiet(l.core, req)) {
pardes.turn.parked = true;
const later: pardes.ctlfs.Reply = .{ .tag = req.tag, .status = .again };
return conn.reply(&later, "");
@@ -263,6 +263,8 @@ pub const Listener = struct {
const core = l.core;
const epoch = pardes.turn.epoch;
const restores = pardes.turn.restores;
+ // Asked first: a release that runs a held line has none after.
+ const changes = pardes.ctlfs.changesPane(core, req);
const reply = core.serveFs(req);
if (req.op == .release and quiet) l.collectOs();
// A read with nothing yet stays parked in the engine, and the core
@@ -289,7 +291,7 @@ pub const Listener = struct {
o.held = .{ .asker = conn, .req = req, .ticket = ticket };
return;
}
- if (!pardes.ctlfs.changesPane(req)) return conn.reply(&reply, core.fsPayload(reply));
+ if (!changes) return conn.reply(&reply, core.fsPayload(reply));
// The editor draws the change and performs what it asked for; when
// it asked for something -- a save, a shell, a watch -- the answer
// waits until that is done, so `echo Save > exec` returns with the
@@ -314,6 +316,9 @@ pub const Listener = struct {
// settle in the same wait is told to this write too.
if (l.core == core and core.fs.late_failure_len != 0) {
const failed = pardes.ctlfs.failText(req.tag, pardes.ctlfs.E.IO, core.fs.late_failure[0..core.fs.late_failure_len]);
+ // Its err record says it (the path in it); the msg the failure
+ // was also said as goes, as a builtin's failing a write does.
+ pardes.ctlfs.events.dropMessage(core, core.fs.late_failure[0..core.fs.late_failure_len]);
pardes.ctlfs.events.noteError(core, req, failed);
return conn.reply(&failed, "");
}
@@ -1756,6 +1761,16 @@ pub const Client = struct {
return fetchBytes(gpa, sock, names[0..n], &remote, null, display_path, @min(max_bytes, limits.max_file_bytes));
}
+ /// Whether a peer answers at `dial`: a version and attach, and its root
+ /// read. Any answer from the peer, an error of its own included, is one.
+ pub fn probe(gpa: std.mem.Allocator, dial: []const u8) !void {
+ const got = readLimit(gpa, dial, "", "", 0) catch |err| switch (err) {
+ error.IsDirectory, error.NotFound, error.Remote, error.FileTooLarge => return,
+ else => return err,
+ };
+ gpa.free(got);
+ }
+
pub fn write(gpa: std.mem.Allocator, dial: []const u8, path: []const u8, bytes: []const u8) !void {
if (comptime !supported) return error.Unsupported;
var names: [max_depth][]const u8 = undefined;
diff --git a/src/File.zig b/src/File.zig
index 6a1ac319..5f6d7097 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -58,6 +58,15 @@ pub const State = struct {
/// as an undo to the save point is in acme.
saved_hash: ?u64 = null,
watch_after_save: bool = false,
+ /// The file changed on disk under unsaved edits, which were kept (see
+ /// `changed`): its hash, so the same change is said once, until a Save
+ /// or get settles it. A Save warns once before overwriting it, as acme's
+ /// Put does (exec.c:577, "modified since last read").
+ disk_newer: ?u64 = null,
+ disk_newer_warned: bool = false,
+ /// Its file was deleted on disk: said once (`deleted`) until it is
+ /// written or found again.
+ disk_gone: bool = false,
/// Seconds of the last content change, for the filesystem's stat; zero until edited.
mtime: u32 = 0,
/// Non-null for a generated output buffer rather than an on-disk file.
@@ -809,6 +818,10 @@ pub fn restore(p: *Pardes, id: usize, src: dump.Pane) !*Pane {
.location_rows = location_rows,
.scroll = src.scroll,
.revision = @intFromBool(saved.dirty),
+ // Restored with unsaved text, it has text to lose as an edited pane
+ // has: a hash its text does not match, so Exit, Restore, Del and
+ // get ask first (builtins.warnModifiedIn reads only edited panes).
+ .saved_hash = if (saved.dirty) std.hash.Wyhash.hash(0, content) +% 1 else null,
.history = history,
};
pane.file.?.tree_context = saved.tree_context and supportsContext(pane);
@@ -960,12 +973,48 @@ pub fn redo(p: *Pardes, pane: *Pane) void {
restoreSnap(pane, f, next);
}
-/// Commit an externally rewritten file onto the same undo history as typed
-/// edits. Unsaved work remains one `u` away; there is no third merge state.
+/// A pane's file deleted on disk: the text stays, and it says so once,
+/// `<file> deleted on disk (Save writes it again)`, logged `changed
+/// <serial> deleted`.
+pub fn deleted(p: *Pardes, id: u8) void {
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ if (f.disk_gone) return;
+ f.disk_gone = true;
+ var said: [limits.host_path_cap + 64]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&said, "{s} deleted on disk (Save writes it again)", .{f.path}) catch "deleted on disk (Save writes it again)");
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} deleted", .{pane.serial}) catch "");
+}
+
+/// A file rewritten on disk. A clean buffer takes the new text, committed
+/// onto the same undo history as typed edits. A buffer with unsaved edits
+/// is never reloaded under them: it keeps its text and stays dirty, and
+/// says so once (`<file> changed on disk (get reloads it, Save overwrites
+/// it)`, logged `changed <serial>`); get then asks before throwing
+/// the edits away and Save before overwriting the newer file. acme reloads
+/// nothing by itself: its get asks (winclean) and its Put refuses a file
+/// modified since it was read (exec.c:577).
pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
const pane = p.panes[id] orelse return;
const f = if (pane.file) |*file| file else return;
+ f.disk_gone = false;
if (std.mem.eql(u8, f.content, bytes)) return;
+ if (f.saved_hash) |saved| if (f.revision != f.saved_revision and std.hash.Wyhash.hash(0, f.content) != saved) {
+ const disk = std.hash.Wyhash.hash(0, bytes);
+ if (f.disk_newer == disk) return;
+ f.disk_newer = disk;
+ f.disk_newer_warned = false;
+ var said: [limits.host_path_cap + 64]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&said, "{s} changed on disk (get reloads it, Save overwrites it)", .{f.path}) catch "changed on disk (get reloads it, Save overwrites it)");
+ var rec: [16]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d}", .{pane.serial}) catch "");
+ return;
+ };
+ f.disk_newer = null;
+ // Logged as the dirty case is, with what became of it.
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch "");
const new = p.gpa.dupe(u8, bytes) catch return;
pushUndo(p, pane);
setContent(p, f, new);
diff --git a/src/Messages.zig b/src/Messages.zig
index 36cd44da..ce7a2a8c 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -412,9 +412,8 @@ fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
if (text.len == 0) return;
const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
const kept = text[0..@min(text.len, LoggedMessage.cap)];
- // /log hears every one: a client that retried and failed the same way
- // is waiting on that second line. Only the +Messages view collapses.
- ctlfs.events.noteMessage(p, serial, kept);
+ // /log hears every one (a failure of a ctl write as its err record).
+ if (!p.fs.unlogged) ctlfs.events.noteMessage(p, serial, text);
if (p.messages.len > 0) {
const last = &p.messages.log[(p.messages.head + limits.message_log - 1) % limits.message_log];
if (last.serial == serial and
@@ -444,17 +443,40 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
return &m.log[(first + i) % limits.message_log];
}
-/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
/// What a route that would open a pane says when every slot is taken.
-pub const no_pane_slots = "no pane slots: {d} max";
+/// "no space": 9ns's word for ENOSPC (it has none for ENFILE).
+pub const no_pane_slots = "no space for a pane: {d} max";
+/// ...and when the column it would go in has no rows for one.
+pub const no_pane_room = "no space for a pane in that column: each keeps its tag and {d} rows";
+
+/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
+/// Why a peer could not be reached, in words, for an error of a 9P dial
+/// (src/9p_io.zig, Client); null for any other error.
+pub fn dialReason(err: anyerror) ?[]const u8 {
+ return switch (err) {
+ error.Dial => "no answer",
+ error.Timeout => "timed out",
+ error.Hangup => "hung up",
+ error.Botch => "protocol botch",
+ else => null,
+ };
+}
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
var w = std.Io.Writer.fixed(&buf);
w.print("{s}: ", .{operation}) catch {};
+ // A peer out of reach says so, not the error's name (`dial`).
+ if (dialReason(err)) |why| {
+ w.print("dial failed: {s}", .{why}) catch {};
+ return reportFailure(p, id, w.buffered());
+ }
// Every route that opens a pane says the cap the same way.
- if (err == error.NoPaneSlots) {
- w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {};
+ if (err == error.NoPaneSlots or err == error.NoPaneRoom) {
+ if (err == error.NoPaneSlots)
+ w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {}
+ else
+ w.print(no_pane_room, .{pardes.layout.min_body_rows}) catch {};
// A 9P look or exec that would open the pane fails with it too.
p.fs.no_pane_slot = true;
@memcpy(p.fs.ename[0..w.buffered().len], w.buffered());
@@ -480,9 +502,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
+ p.fs.failures +%= 1;
+ // A failure said later, when the host could not do the work (a Save's
+ // write): that builtin's announcement goes from the log too.
+ ctlfs.events.dropFailedAnnouncement(p, text, false);
// A builtin a ctl write runs: its first error is also the write's, cut
// between words.
- if (p.fs.no_prompt and p.fs.failure_len == 0) {
+ const failing_write = p.fs.no_prompt or p.fs.capturing;
+ if (failing_write and p.fs.failure_len == 0) {
const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
@memcpy(p.fs.failure[0..kept.len], kept);
var n = kept.len;
@@ -492,6 +519,16 @@ pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
}
p.fs.failure_len = @intCast(n);
}
+ // The write fails with it, and its err record says it: no msg for it,
+ // and no Verbose announcement before that, so the same failure again is
+ // the same record again, counted (events.pushCounting).
+ if (failing_write) {
+ const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
+ ctlfs.events.dropAnnouncement(p, serial, text);
+ p.fs.unlogged = true;
+ defer p.fs.unlogged = false;
+ return setMessage(p, id, text);
+ }
setMessage(p, id, text);
}
diff --git a/src/Output.zig b/src/Output.zig
index 2f462fd1..b32a40a2 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -499,7 +499,7 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
const loc: []const u8 = if (has_path)
(if (jp.file) |f| f.path else pdf_path.?)
else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{j.pane}) catch unreachable;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{jp.serial}) catch unreachable;
const what: []const u8 = if (jp.file) |f|
std.mem.trim(u8, modal.lineSlice(f.content, j.line -| 1), " \t\r")
else if (jp.image) |iv|
@@ -665,7 +665,7 @@ pub fn openConfig(p: *Pardes, id: usize) !void {
.startup_config_path = p.opts.startup_config_path,
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
- .compiled_default_shell = config.default_shell,
+ .compiled_default_shell = config.defaultShell(),
.gui_shader_source_mode = if (gui_shader_source_mode) |mode|
mode.label()
else
diff --git a/src/builtins.zig b/src/builtins.zig
index 32e3cd84..3f4a680c 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -170,9 +170,10 @@ pub const registry = struct {
if (@intFromEnum(b) == i) return @hasDecl(T, "requires_arg") and T.requires_arg;
inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
if (@intFromEnum(b) == i) return switch (setting.action) {
- // a switch flips bare, a choice steps, and DumpDir bare is the default
- .toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
+ // a switch flips bare, a choice steps, and DumpDir and Shell bare
+ // are the default
+ .toggle, .transition, .scene, .dump_dir, .choice, .lift, .shader_animation, .motion, .shell => false,
+ .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .inactive_dim, .grip_width => true,
};
unreachable;
}
@@ -234,6 +235,7 @@ test "Exit asks once about unsaved text, and quits when asked again" {
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(!p.quit);
try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)"));
@@ -242,26 +244,41 @@ test "Exit asks once about unsaved text, and quits when asked again" {
try std.testing.expect(p.quit);
}
-test "Exit over a ctl write names every pane it asks about, and the next Exit discards them all" {
- const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
- defer p.deinit();
- const first = try p.setTestFile("saved\n");
- first.file.?.saved_revision = first.file.?.revision -% 1;
- p.newScratchBelow(p.active);
- const second = p.panes[p.active].?;
- panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "x" ** 120));
- try std.testing.expect(second.file.?.revision != second.file.?.saved_revision);
- p.fs.no_prompt = true;
- defer p.fs.no_prompt = false;
- try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
- try std.testing.expect(!p.quit);
- const said = p.fs.failure[0..p.fs.failure_len];
- try std.testing.expect(std.mem.indexOf(u8, said, first.file.?.path) != null);
- try std.testing.expect(std.mem.indexOf(u8, said, second.file.?.path) != null);
- try std.testing.expect(std.mem.endsWith(u8, said, ": Modified (Exit again to discard)"));
- p.fs.failure_len = 0;
- try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
- try std.testing.expect(p.quit);
+test "Exit over a ctl write or a click names every pane it asks about, a second only those edited since, and the next discards them all" {
+ for ([_]bool{ false, true }) |clicked| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const first = try p.setTestFile("saved\n");
+ first.file.?.saved_revision = first.file.?.revision -% 1;
+ first.file.?.saved_hash = 0; // edited: what it had is not what it has
+ p.newScratchBelow(p.active);
+ const second = p.panes[p.active].?;
+ panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "x" ** 120));
+ try std.testing.expect(second.file.?.revision != second.file.?.saved_revision);
+ // A ctl write, or a click through exec (ctl.captured).
+ if (clicked) p.fs.capturing = true else p.fs.no_prompt = true;
+ defer p.fs.capturing = false;
+ defer p.fs.no_prompt = false;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ // Two: counted, never a list cut short; `unsaved` records name them.
+ try std.testing.expectEqualStrings("2 unsaved panes: Modified (Exit again to discard)", p.fs.failure[0..p.fs.failure_len]);
+ const log = p.fs.log.buf.items;
+ var rec: [64]u8 = undefined;
+ for ([_]*const Pane{ first, second }) |pane|
+ try std.testing.expect(std.mem.indexOf(u8, log, try std.fmt.bufPrint(&rec, "unsaved {d} ", .{pane.serial})) != null);
+ // Edited since: only that one is named again (acme's rule).
+ p.fs.failure_len = 0;
+ panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "y" ** 120));
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(!p.quit);
+ const again = p.fs.failure[0..p.fs.failure_len];
+ try std.testing.expect(std.mem.indexOf(u8, again, first.file.?.path) == null);
+ try std.testing.expect(std.mem.startsWith(u8, again, second.file.?.path));
+ p.fs.failure_len = 0;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
+ try std.testing.expect(p.quit);
+ }
}
/// A dump file that exists, for a Restore to get past reading it.
@@ -282,6 +299,7 @@ test "Restore asks about unsaved text as Exit does, and restores when asked agai
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
var line_buf: [4200]u8 = undefined;
const line = try testDump(&line_buf);
try std.testing.expect(p.executeBuiltinLine(p.active, line));
@@ -296,6 +314,7 @@ test "a Restore of a dump that cannot be read says so, before the unsaved-text w
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
p.fs.no_prompt = true;
defer p.fs.no_prompt = false;
try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /nope/missing.zon"));
@@ -310,6 +329,7 @@ test "a Restore's warning is not an Exit's: each word is warned on its own" {
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
var line_buf: [4200]u8 = undefined;
try std.testing.expect(p.executeBuiltinLine(p.active, try testDump(&line_buf)));
try std.testing.expect(p.restore_req == null);
@@ -394,7 +414,7 @@ test "Ligatures is a setting only in the shell that shapes text" {
.startup_config_path = null,
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
- .compiled_default_shell = config.default_shell,
+ .compiled_default_shell = config.defaultShell(),
.hover_delay_frames = null,
.native_images = false,
.capabilities = capabilities,
@@ -413,6 +433,7 @@ comptime {
// ---- the two acme verbs ----
+/// Look at its argument: open the file or address it names, else find the word, as a right click does.
pub const Look = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -424,6 +445,7 @@ pub const Look = struct {
}
};
+/// Execute its argument as a middle click on it would, a builtin or a shell command.
pub const Exec = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -454,11 +476,12 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool {
const Asked = union(enum) { all, pane: usize, column: usize };
fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
- var modified = false;
- // Every pane asked about is named in the one refusal a ctl write gets,
- // as acme's rowclean names them all (wind.c:511).
- var all_buf: [256]u8 = undefined;
- var all = std.Io.Writer.fixed(&all_buf);
+ // Every pane asked about is an `unsaved <serial> <name>` record, as
+ // acme's rowclean names them all (wind.c:511); the refusal a write
+ // gets names the one, or counts them, never a list cut short.
+ var count: usize = 0;
+ var one_buf: [limits.host_path_cap + 24]u8 = undefined;
+ var one: []const u8 = "";
for (c.p.panes, 0..) |slot, id| {
const pane = slot orelse continue;
switch (which) {
@@ -474,28 +497,39 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue;
if (f.output != null and f.content.len < 100) continue;
// Dirty by a rename alone is no text to lose, as get says (ctl.zig).
- // Nor is an empty buffer that never held a file.
- if (f.saved_hash) |saved| {
- if (std.hash.Wyhash.hash(0, f.content) == saved) continue;
- } else if (f.content.len == 0) continue;
+ // Nor is a buffer never edited at all: its first edit is what keeps
+ // the text it had (File.zig), so with none there is nothing to lose.
+ const saved = f.saved_hash orelse continue;
+ if (std.hash.Wyhash.hash(0, f.content) == saved) continue;
if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue;
pane.discard_warned = .{ .revision = f.revision, .by = asking };
- var buf: [limits.host_path_cap + 48]u8 = undefined;
- c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ f.path, @tagName(asking) }) catch f.path);
- all.print("{s}{s}", .{ if (modified) ", " else "", f.path }) catch {};
- modified = true;
+ // Scratches share a name: each is named with its pane's serial too,
+ // `/dir/+New (pane 12)`, the same in every refusal (Exit, Restore,
+ // Del, Delcol).
+ var name_buf: [limits.host_path_cap + 24]u8 = undefined;
+ const name = if (scratch) std.fmt.bufPrint(&name_buf, "{s} (pane {d})", .{ f.path, pane.serial }) catch f.path else f.path;
+ var buf: [limits.host_path_cap + 72]u8 = undefined;
+ pardes.ctlfs.events.noteLog(c.p, .unsaved, pane);
+ c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ name, @tagName(asking) }) catch name);
+ if (count == 0) one = std.fmt.bufPrint(&one_buf, "{s}", .{name}) catch name;
+ count += 1;
}
- if (modified and c.p.fs.no_prompt) {
- all.print(": Modified ({s} again to discard)", .{@tagName(asking)}) catch {};
- const said = @import("Messages.zig").clip(all.buffered(), c.p.fs.failure.len);
- @memcpy(c.p.fs.failure[0..said.len], said);
- c.p.fs.failure_len = @intCast(said.len);
+ if (count > 0 and (c.p.fs.no_prompt or c.p.fs.capturing)) {
+ var said_buf: [limits.host_path_cap + 96]u8 = undefined;
+ const said = (if (count == 1)
+ std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) })
+ else
+ std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified";
+ const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len);
+ @memcpy(c.p.fs.failure[0..kept.len], kept);
+ c.p.fs.failure_len = @intCast(kept.len);
}
- return modified;
+ return count > 0;
}
-/// acme's Exit (editors/acme/exec.c:892): quit, once warnModified has
-/// nothing to say.
+/// Quit the editor, refusing once while a pane holds unsaved text: an Exit
+/// with nothing edited since discards it (acme's Exit, editors/acme/exec.c:892,
+/// asking warnModified first).
pub const Exit = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -557,6 +591,7 @@ pub const Kill = struct {
}
};
+/// Write the session's panes and layout to a dump file, for Restore.
pub const Dump = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -564,6 +599,7 @@ pub const Dump = struct {
}
};
+/// Replace every pane with those of a dump file, the last one Dump wrote when bare.
pub const Restore = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -599,6 +635,7 @@ pub const Restore = struct {
}
};
+/// Attach this terminal to a detached pardes session by name.
pub const Attach = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -614,6 +651,7 @@ pub const Attach = struct {
}
};
+/// Detach this pardes from its terminal, leaving the session running.
pub const Detach = struct {
pub const scope: Scope = .session;
pub const enabled = pardes.can_attach;
@@ -625,6 +663,7 @@ pub const Detach = struct {
}
};
+/// Mount another pardes's 9P tree under /n/<name>, given its dial address.
pub const Mount = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -637,9 +676,25 @@ pub const Mount = struct {
const dial = std.mem.trim(u8, args.rest(), " \t");
if (dial.len == 0) return c.p.reportError(c.id, "Mount name dial", error.MissingArgument);
c.p.fs.mount(c.p.gpa, name, dial) catch |err| return c.p.reportError(c.id, "Mount", err);
+ // A mount that cannot be reached is said now, and taken back, not
+ // found out by the first look through it.
+ const reached = reached: {
+ pardes.turn.yield();
+ defer pardes.turn.back();
+ break :reached @import("9p_io.zig").Client.probe(c.p.gpa, dial);
+ };
+ reached catch |err| {
+ if (c.p.fs.mounts.pop()) |gone| {
+ c.p.gpa.free(gone.name);
+ c.p.gpa.free(gone.dial);
+ }
+ var what: [160]u8 = undefined;
+ return c.p.reportError(c.id, std.fmt.bufPrint(&what, "Mount {s} {s}", .{ name, dial[0..@min(dial.len, 100)] }) catch "Mount", err);
+ };
}
};
+/// Unmount the 9P tree mounted under /n/<name>.
pub const Unmount = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -656,6 +711,7 @@ pub const Unmount = struct {
// ---- the message row ----
+/// Show its argument on this pane's message row.
pub const Msg = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -668,6 +724,7 @@ pub const Msg = struct {
}
};
+/// Switch to the next bundled theme.
pub const NextColor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -675,6 +732,7 @@ pub const NextColor = struct {
}
};
+/// List the themes in a pane, each a word to click.
pub const ThemeSel = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.themes_buffer, .steps = true, .commands = true };
@@ -683,6 +741,7 @@ pub const ThemeSel = struct {
}
};
+/// Load a theme from a file.
pub const ThemeFile = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -696,6 +755,7 @@ pub const ThemeFile = struct {
}
};
+/// Write the bundled themes into the config directory's themes, to edit.
pub const DumpThemes = struct {
pub const scope: Scope = .session;
pub const enabled = pardes.hosted;
@@ -710,6 +770,7 @@ pub const DumpThemes = struct {
}
};
+/// List the fonts in a pane, each a word to click.
pub const FontSel = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.fonts_buffer, .steps = true, .commands = true };
@@ -722,6 +783,7 @@ pub const FontSel = struct {
}
};
+/// Show the declarations enclosing the top row at the pane's head, on or off.
pub const TreeContext = struct {
pub const enabled = pardes.syntax.enabled;
pub const takes_arg = true;
@@ -746,6 +808,7 @@ pub const TreeContext = struct {
}
};
+/// Fit the PDF page to the pane by width or by height, flipping between them.
pub const PdfFit = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
@@ -756,6 +819,7 @@ pub const PdfFit = struct {
}
};
+/// Tint the PDF page to the theme, or show it as printed, flipping between them.
pub const PdfTint = struct {
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
@@ -766,6 +830,7 @@ pub const PdfTint = struct {
}
};
+/// List the PDF's sections in a pane, each a place to go.
pub const PdfSections = struct {
pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true, .locations = true };
pub const enabled = pardes.pdf_enabled;
@@ -800,6 +865,7 @@ pub const Ascii = struct {
// ---- the system clipboard ----
+/// Copy the selection to the system clipboard.
pub const ClipYank = struct {
pub fn run(c: Ctx) void {
edit.clipYank(c.p, c.pane, false);
@@ -814,18 +880,21 @@ pub const ClipYankMain = struct {
}
};
+/// Paste the system clipboard after the selection.
pub const ClipPaste = struct {
pub fn run(c: Ctx) void {
edit.clipRequest(c.p, c.id, .after);
}
};
+/// Paste the system clipboard before the selection.
pub const ClipPasteBefore = struct {
pub fn run(c: Ctx) void {
edit.clipRequest(c.p, c.id, .before);
}
};
+/// Replace the selection with the system clipboard.
pub const ClipReplace = struct {
pub fn run(c: Ctx) void {
edit.clipRequest(c.p, c.id, .replace);
@@ -834,6 +903,7 @@ pub const ClipReplace = struct {
// ---- panes and columns ----
+/// Write the pane's text to its file, or to the file its argument names.
pub const Save = struct {
pub const takes_arg = true;
pub fn run(c: Ctx) void {
@@ -844,6 +914,7 @@ pub const Save = struct {
}
};
+/// Open an empty scratch pane, <dir>/+New, in this pane's directory.
pub const New = struct {
pub const output: OutputTraits = .{ .name = config.scratch_buffer, .doc = true, .saves = true };
pub fn run(c: Ctx) void {
@@ -900,6 +971,7 @@ pub const Del = struct {
}
};
+/// Recolour a terminal's program colours to the theme, or show them as sent, flipping between them.
pub const Filter = struct {
pub fn run(c: Ctx) void {
if (!c.pane.isTerminal()) return;
@@ -954,14 +1026,20 @@ pub const Edit = struct {
/// acme's Undo and Redo (exec.c `undo`): step the pane's body back or
/// forward through its edits, from a tag, a pane ctl or exec.
+/// With no step left, Undo and Redo say so and change nothing; a ctl write
+/// of them still succeeds, as acme's Undo is silent there. A file keeps
+/// limits.undo_max steps (256).
pub const Undo = struct {
pub fn run(c: Ctx) void {
+ if (c.pane.file) |f| if (f.history.undo_len == 0) return c.p.setMessage(c.id, "Undo: nothing to undo");
edit.doUndo(c.p, &c.pane.body);
}
};
+/// Redo the last undone edit.
pub const Redo = struct {
pub fn run(c: Ctx) void {
+ if (c.pane.file) |f| if (f.history.redo_len == 0) return c.p.setMessage(c.id, "Redo: nothing to redo");
edit.doRedo(c.p, &c.pane.body);
}
};
@@ -973,6 +1051,7 @@ pub const Collapse = struct {
}
};
+/// Close this column and its panes, refusing once over unsaved text.
pub const Delcol = struct {
pub fn run(c: Ctx) void {
// The column of the tag it came from, else the pane's: a pane ctl's
@@ -985,12 +1064,14 @@ pub const Delcol = struct {
}
};
+/// Close the pane, giving its rows to the pane above it.
pub const DelAbove = struct {
pub fn run(c: Ctx) void {
Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "k" });
}
};
+/// Close the pane, giving its rows to the pane below it.
pub const DelBelow = struct {
pub fn run(c: Ctx) void {
Del.run(.{ .p = c.p, .pane = c.pane, .id = c.id, .txt = c.txt, .arg = "j" });
@@ -1009,10 +1090,10 @@ pub const Tty = struct {
// A shell that is not there is said, where the host would quietly
// start its fallback in its place.
if (comptime pardes.hosted) if (arg.len > 0) {
- var buf: [std.fs.max_path_bytes]u8 = undefined;
- if (@import("host_io.zig").Shell.find(arg, &buf) == null) {
- var said: [320]u8 = undefined;
- return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: no shell \"{s}\" (a name on the usual paths, or a path to one)", .{arg[0..@min(arg.len, 200)]}) catch "Tty: no such shell");
+ var why: [320]u8 = undefined;
+ if (@import("host_io.zig").Shell.refusal(arg[0..@min(arg.len, 200)], &why)) |refused| {
+ var said: [340]u8 = undefined;
+ return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "Tty: {s}", .{refused}) catch "Tty: no such shell");
}
};
const pane = exec.spawnTty(c.p, c.id) orelse return;
@@ -1028,8 +1109,8 @@ pub const Tty9p = struct {
}
};
-/// Fold the active pane's column into the one on its right, keeping its panes.
-/// Binds this terminal as a language's REPL, by the language's name as the
+/// Binds this terminal as a language's REPL, so an exec on that language's
+/// files is typed into it instead of run. The language is named as the
/// syntax table has it: an exec made on the body of a file in that
/// language -- a middle click, the execute key, a single word like `ls`
/// as much as a selection -- and one made on this terminal's own body, is
@@ -1064,10 +1145,25 @@ pub const Repl = struct {
// Named as a code fence names it: `py`, `Python`, `sh`.
const named: ?u8 = if (manifest.byName(arg)) |i| if (grammars[i].exts.len > 0) @intCast(i) else null else null;
const lang: u8 = named orelse {
+ // A few, whole, and where the rest are: a list cut to fit the
+ // row would end mid-word.
var w = std.Io.Writer.fixed(&buf);
- w.print("Repl: no language \"{s}\"; - or one of:", .{@import("Messages.zig").clip(arg, 24)}) catch {};
- for (grammars) |g| if (g.exts.len > 0) w.print(" {s}", .{g.name}) catch break;
- return c.p.reportFailure(c.id, @import("Messages.zig").clip(w.buffered(), 256));
+ w.print("Repl: no language \"{s}\"; - or one like", .{@import("Messages.zig").clip(arg, 24)}) catch {};
+ // Those that start as the word does first, a misspelling's
+ // likeliest meaning (pithon: python).
+ var shown: usize = 0;
+ var known: usize = 0;
+ for ([2]bool{ true, false }) |alike| for (grammars) |g| if (g.exts.len > 0) {
+ const starts = arg.len > 0 and g.name[0] == std.ascii.toLower(arg[0]);
+ if (starts != alike) continue;
+ known += 1;
+ if (shown < 6) {
+ w.print(" {s}", .{g.name}) catch {};
+ shown += 1;
+ }
+ };
+ w.print(" ({d} more: docs/tags.md, Repl)", .{known - shown}) catch {};
+ return c.p.reportFailure(c.id, w.buffered());
};
if (pane.repl) |r| if (r.lang == lang)
return c.p.setMessage(c.id, std.fmt.bufPrint(&buf, "Repl: {s}", .{exec.replId(&idbuf, pane)}) catch "Repl");
@@ -1088,6 +1184,7 @@ pub const Repl = struct {
}
};
+/// Fold the active pane's column into the one on its right, keeping its panes.
/// The horizontal mirror of the vertical stacking `New` does.
pub const Joincol = struct {
pub const scope: Scope = .session;
@@ -1099,6 +1196,7 @@ pub const Joincol = struct {
}
};
+/// Open the tutor, a document to work through.
pub const Tutor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
@@ -1108,6 +1206,7 @@ pub const Tutor = struct {
}
};
+/// Open the help: every key and every builtin.
pub const Help = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.help_buffer };
@@ -1116,6 +1215,7 @@ pub const Help = struct {
}
};
+/// Open the settings in effect, each a word to click.
pub const Config = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.config_buffer };
@@ -1124,6 +1224,7 @@ pub const Config = struct {
}
};
+/// Set how location rows are drawn, or show how they are when bare.
pub const LocationsConfig = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1154,6 +1255,7 @@ pub const LocationsConfig = struct {
}
};
+/// Open the messages shown so far.
pub const Messages = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.messages_buffer };
@@ -1172,6 +1274,7 @@ pub const Changelog = struct {
}
};
+/// Open the source of a panel transition or post shader, by name.
pub const EffectCode = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1199,6 +1302,7 @@ pub const Pet = struct {
}
};
+/// Open a minimap of the file in braille dots.
pub const Mini = struct {
pub const takes_arg = true;
pub const output: OutputTraits = .{ .name = "Mini", .doc = true };
@@ -1211,6 +1315,7 @@ pub const Mini = struct {
// ---- search ----
+/// List every place a pattern matches in the pane, in a +Search.
pub const Find = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1247,6 +1352,7 @@ pub const Grep = struct {
// nothing above it reaches its column's tag, then the workspace's, and Down
// comes back the same way. Left and Right walk the column tags.
+/// Move the keyboard to the pane, or column tag, to the left.
pub const Left = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1257,6 +1363,7 @@ pub const Left = struct {
}
};
+/// Move the keyboard to the pane below, off a tag onto the pane under it.
pub const Down = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1275,6 +1382,7 @@ pub const Down = struct {
}
};
+/// Move the keyboard to the pane above, and from a column's top pane to its tag.
pub const Up = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1288,6 +1396,7 @@ pub const Up = struct {
}
};
+/// Move the keyboard to the pane, or column tag, to the right.
pub const Right = struct {
pub fn run(c: Ctx) void {
if (c.p.header_focus) {
@@ -1316,6 +1425,7 @@ pub const Forward = struct {
}
};
+/// Go back to the last place jumped from in another pane.
pub const Last = struct {
pub fn run(c: Ctx) void {
var i = c.p.njumps;
@@ -1331,6 +1441,7 @@ pub const Last = struct {
}
};
+/// List the places jumped from, each a place to go.
pub const Jumplist = struct {
pub const scope: Scope = .session;
pub const output: OutputTraits = .{ .name = config.jumps_buffer, .steps = true, .locations = true };
@@ -1341,66 +1452,77 @@ pub const Jumplist = struct {
// ---- the language group ----
+/// Ask the language server about the symbol under the cursor.
pub const Hover = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .hover, "");
}
};
+/// List the language server's code actions for the cursor.
pub const CodeAction = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .code_action, "");
}
};
+/// Select every reference to the symbol under the cursor.
pub const SelectRefs = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .select_refs, "");
}
};
+/// List the file's symbols from the language server.
pub const Symbols = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .document_symbols, "");
}
};
+/// List the file's diagnostics from the language server.
pub const Diagnostics = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .diagnostics, "");
}
};
+/// List the workspace's diagnostics from the language server.
pub const WsDiagnostics = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .workspace_diagnostics, "");
}
};
+/// List the callers of the function under the cursor.
pub const Callers = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .incoming_calls, "");
}
};
+/// List the functions the function under the cursor calls.
pub const Callees = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .outgoing_calls, "");
}
};
+/// List the supertypes of the type under the cursor.
pub const Supertypes = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .supertypes, "");
}
};
+/// List the subtypes of the type under the cursor.
pub const Subtypes = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .subtypes, "");
}
};
+/// Rename the symbol under the cursor everywhere, to its argument.
pub const Rename = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1411,6 +1533,7 @@ pub const Rename = struct {
}
};
+/// Search the workspace's symbols for its argument.
pub const WsSymbols = struct {
pub const takes_arg = true;
pub const requires_arg = true;
@@ -1421,18 +1544,21 @@ pub const WsSymbols = struct {
}
};
+/// Show which language servers serve this file and their state.
pub const Lspinfo = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .status, "");
}
};
+/// Explain how the last language server request was answered, step by step.
pub const Lspwhy = struct {
pub fn run(c: Ctx) void {
c.p.lspRequest(c.id, .explain, "");
}
};
+/// Read the board memory at an address, into a pane.
pub const Peek = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1447,6 +1573,7 @@ pub const Peek = struct {
}
};
+/// Write a value to the board memory at an address.
pub const Poke = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1460,6 +1587,7 @@ pub const Poke = struct {
}
};
+/// Dump a range of the board memory in hex, into a pane.
pub const Hexdump = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1474,6 +1602,7 @@ pub const Hexdump = struct {
}
};
+/// Read or set the board GPIO pins, into a pane.
pub const Gpio = struct {
pub const scope: Scope = .session;
pub const takes_arg = true;
@@ -1767,18 +1896,28 @@ test "Exit and Restore name a modified +New scratch as well as files" {
defer p.deinit();
const file = try p.setTestFile("saved\n");
file.file.?.saved_revision = file.file.?.revision -% 1; // modified
+ file.file.?.saved_hash = 0; // edited: what it had is not what it has
p.newScratchBelow(p.active);
const scratch = &p.panes[p.active].?.file.?;
scratch.content = try p.gpa.realloc(scratch.content, 120);
@memset(scratch.content, 'n'); // past acme's 100 bytes, where it whines
scratch.revision +%= 1; // typed into, never saved
+ scratch.saved_hash = 0; // edited: what it had is not what it has
p.fs.no_prompt = true;
defer p.fs.no_prompt = false;
try std.testing.expect(p.executeBuiltinLine(p.active, "Exit"));
try std.testing.expect(!p.quit);
- const said = p.fs.failure[0..p.fs.failure_len];
- try std.testing.expect(std.mem.indexOf(u8, said, "test.txt") != null);
- try std.testing.expect(std.mem.indexOf(u8, said, config.scratch_buffer) != null);
+ // Each pane's message row names it; the write's refusal counts them.
+ try std.testing.expectEqualStrings("2 unsaved panes: Modified (Exit again to discard)", p.fs.failure[0..p.fs.failure_len]);
+ try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "test.txt") != null);
+ const scratch_pane = p.panes[p.active].?;
+ var named: [64]u8 = undefined;
+ try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], try std.fmt.bufPrint(&named, "{s} (pane {d})", .{ config.scratch_buffer, scratch_pane.serial })) != null);
+ // Delcol, Restore and Exit name it the same way.
+ p.fs.failure_len = 0;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
+ try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], "(Delcol again") != null);
+ try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], named[0 .. config.scratch_buffer.len + 7]) != null);
}
test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and close on the second" {
@@ -1786,6 +1925,7 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo
defer p.deinit();
const pane = try p.setTestFile("saved\n");
pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified
+ pane.file.?.saved_hash = 0; // edited: what it had is not what it has
const serial = pane.serial;
try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol"));
try std.testing.expect(p.paneBySerial(serial) != null);
@@ -1797,3 +1937,14 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo
try std.testing.expect(p.executeBuiltinLine(p.active, "Del"));
try std.testing.expect(p.paneBySerial(serial) == null);
}
+
+test "a pane renamed but never edited has no text to lose: Del closes it at once" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ p.newScratchBelow(p.active);
+ const pane = try p.setTestFile("loaded\n");
+ pane.file.?.saved_revision = pane.file.?.revision -% 1; // dirty by the rename alone
+ const serial = pane.serial;
+ try std.testing.expect(p.executeBuiltinLine(0, "Del"));
+ try std.testing.expect(p.paneBySerial(serial) == null);
+}
diff --git a/src/config.zig b/src/config.zig
index d4efed0e..229c70ea 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -26,7 +26,7 @@ const undecided: []const u8 = "<undecided>";
// null means word/chord-only. Every enabled builtin must explicitly choose a path.
pub const leader_path = paths: {
- @setEvalBranchQuota(2000);
+ @setEvalBranchQuota(10_000); // a check per builtin, and they grow
var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{
.Help = &[_]u8{leader_help},
.Hover = "lk",
@@ -76,6 +76,7 @@ pub const leader_path = paths: {
.Joincol = "cj",
.Placement = "cp",
.BootShell = null,
+ .LookWord = null,
.Debug = "td",
.Msg = null,
.Colors = "tc",
@@ -350,7 +351,16 @@ test "wheel drift guard" {
try std.testing.expect(wheelTick(&g, false));
}
-pub const default_shell = "fish";
+/// The shell when no `Shell` is set: the user's login shell ($SHELL), as a
+/// terminal would start, else /bin/sh. An explicit `Shell` overrides it.
+pub fn defaultShell() []const u8 {
+ if (comptime pardes.hosted) if (std.c.getenv("SHELL")) |s| {
+ const shell = std.mem.span(s);
+ // One that is not there, or not executable, is no shell: /bin/sh.
+ if (shell.len > 0 and std.c.access(s, std.c.X_OK) == 0) return shell;
+ };
+ return "/bin/sh";
+}
// Minimum file gutter width, including the space after the line number.
pub const PREFIX_W: u16 = 5;
@@ -708,12 +718,17 @@ pub const Runtime = struct {
/// has typed into stands: `keep` leaves the shell, `replace` closes it
/// (exec.evictLonePristineTty).
boot_shell: BootShell = .keep,
+ /// A plain word looked at in a pane: `search`, acme's look3, selects its
+ /// next place in that pane, wrapping; `list`, what pardes did before,
+ /// lists every place in a +Search pane (look.zig, lookWord).
+ look_word: LookWord = .search,
pub const ShaderAnimation = enum { off, on, always };
pub const Placement = enum { acme, pardes };
pub const BootShell = enum { keep, replace };
+ pub const LookWord = enum { search, list };
/// A setting that is one of a few words, each an enum field of Runtime.
- pub const Choice = enum { placement, boot_shell };
+ pub const Choice = enum { placement, boot_shell, look_word };
/// shadow: a soft drop shadow. rim: a light hairline along the top of
/// the focused pane's tag rows. auto: a shadow on a light page; on a
@@ -912,6 +927,8 @@ pub const Runtime = struct {
pub const Setting = struct {
word: []const u8,
action: Action,
+ /// What it does, one sentence: /commands lists it after the word.
+ doc: []const u8,
availability: ?Capability = null,
pub fn enabled(setting: Setting, capabilities: Capabilities) bool {
@@ -924,51 +941,52 @@ pub const Runtime = struct {
// The builtin registry and Config report share this command table.
pub const settings = [_]Setting{
- .{ .word = "Colors", .action = .{ .toggle = .colors } },
- .{ .word = "Wrap", .action = .{ .toggle = .wrap } },
- .{ .word = "TreeContextTagStyle", .action = .{ .toggle = .tree_context_tag_style } },
- .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
- .{ .word = "Debug", .action = .{ .toggle = .debug } },
- .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } },
- .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } },
- .{ .word = "Verbose", .action = .{ .toggle = .verbose } },
- .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
- .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures },
- .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift },
- .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift },
- .{ .word = "Lift", .action = .lift, .availability = .lift },
- .{ .word = "Motion", .action = .motion },
- .{ .word = "InactiveDim", .action = .inactive_dim },
- .{ .word = "GripWidth", .action = .grip_width, .availability = .lift },
- .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } },
- .{ .word = "MessageFall", .action = .{ .message_ms = .fall } },
- .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } },
- .{ .word = "DumpDir", .action = .dump_dir },
- .{ .word = "Theme", .action = .theme },
- .{ .word = "Shell", .action = .shell },
- .{ .word = "Font", .action = .font, .availability = .font_picker },
- .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker },
- .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity },
- .{ .word = "WindowBlur", .action = .window_blur, .availability = .window_blur },
- .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions },
- .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions },
- .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions },
- .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions },
- .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions },
- .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions },
- .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions },
- .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions },
- .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions },
- .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions },
- .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions },
- .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
- .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders },
- .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders },
- .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders },
- .{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
- .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
- .{ .word = "Placement", .action = .{ .choice = .placement } },
- .{ .word = "BootShell", .action = .{ .choice = .boot_shell } },
+ .{ .word = "Colors", .action = .{ .toggle = .colors }, .doc = "Syntax colours in text panes, on or off." },
+ .{ .word = "Wrap", .action = .{ .toggle = .wrap }, .doc = "Long lines wrap onto the next row, on or off." },
+ .{ .word = "TreeContextTagStyle", .action = .{ .toggle = .tree_context_tag_style }, .doc = "A pane's TreeContext rows draw in the tag's style, on or off." },
+ .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom }, .doc = "Each pane's tag goes at its foot rather than its head, on or off." },
+ .{ .word = "Debug", .action = .{ .toggle = .debug }, .doc = "A small box of frame statistics at the top right, on or off." },
+ .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint }, .doc = "The focused pane's and column's tags are tinted, on or off." },
+ .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold }, .doc = "Keywords and the like are drawn bold, on or off." },
+ .{ .word = "Verbose", .action = .{ .toggle = .verbose }, .doc = "A builtin says its own name on the message row as it runs, on or off." },
+ .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation }, .doc = "A message falls into its row and dissolves out of it, on or off." },
+ .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures, .doc = "A font's programming ligatures draw across their cells, on or off." },
+ .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift, .doc = "The cursor blinks, solid while typing, on or off." },
+ .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift, .doc = "A soft halo of the selection's colour round it in the body, on or off." },
+ .{ .word = "Lift", .action = .lift, .availability = .lift, .doc = "How the focused pane stands off the page: off, shadow, rim or auto." },
+ .{ .word = "Motion", .action = .motion, .doc = "How animations move: off, crisp, smooth, bouncy or playful." },
+ .{ .word = "InactiveDim", .action = .inactive_dim, .doc = "Fade the unfocused panes' text by a percentage, 0 through 100, 0 off." },
+ .{ .word = "GripWidth", .action = .grip_width, .availability = .lift, .doc = "The grip and scrollbar width, as a percent of the theme's rail." },
+ .{ .word = "MessageLinger", .action = .{ .message_ms = .linger }, .doc = "How long, in milliseconds, a message stays after what dismisses it." },
+ .{ .word = "MessageFall", .action = .{ .message_ms = .fall }, .doc = "How long, in milliseconds, a message takes to fall into its row." },
+ .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve }, .doc = "How long, in milliseconds, a message takes to dissolve." },
+ .{ .word = "DumpDir", .action = .dump_dir, .doc = "The directory Dump writes to and a relative Restore reads from; bare, the default." },
+ .{ .word = "Theme", .action = .theme, .doc = "Switch to the named theme." },
+ .{ .word = "Shell", .action = .shell, .doc = "The shell new terminals run; bare, the login shell." },
+ .{ .word = "Font", .action = .font, .availability = .font_picker, .doc = "The body font, by name or path, and its size." },
+ .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker, .doc = "Scale the tag font, a percentage." },
+ .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity, .doc = "The window's opacity, a percentage." },
+ .{ .word = "WindowBlur", .action = .window_blur, .availability = .window_blur, .doc = "The blur behind a translucent window, a radius." },
+ .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions, .doc = "Panes slide in and out as they open and close, on or off." },
+ .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions, .doc = "Panes zoom in and out as they open and close, on or off." },
+ .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions, .doc = "Panes dissolve in and out as they open and close, on or off." },
+ .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions, .doc = "Panes open and close through a scatter of ASCII, on or off." },
+ .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions, .doc = "Panes open and close with a vertical wipe, on or off." },
+ .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions, .doc = "Panes open and close from their edges, on or off." },
+ .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions, .doc = "Panes fall into place as they open, on or off." },
+ .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions, .doc = "Panes open and close in a wave, on or off." },
+ .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions, .doc = "Panes open and close like a curtain, on or off." },
+ .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions, .doc = "Panes open and close through scrambled text, on or off." },
+ .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions, .doc = "Panes type themselves in as they open, on or off." },
+ .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders, .doc = "The bundled CRT post pass over the window, on or off." },
+ .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders, .doc = "The brightest ink glows a little: a level, 0 (off) through 3." },
+ .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders, .doc = "The window's corners fall into shade: a level, 0 (off) through 3." },
+ .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders, .doc = "The page takes a fine, still grain, like paper: a level, 0 (off) through 3." },
+ .{ .word = "Shader", .action = .shader, .availability = .scene_shaders, .doc = "A Shadertoy file into the post chain, or out of it; off empties the chain." },
+ .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders, .doc = "When the post chain animates on its own: off, on (while focused) or always." },
+ .{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." },
+ .{ .word = "BootShell", .action = .{ .choice = .boot_shell }, .doc = "A document dropped where a lone untouched shell stands: keep the shell, or replace it." },
+ .{ .word = "LookWord", .action = .{ .choice = .look_word }, .doc = "A plain word looked at: search selects its next place in the pane, list lists every place in a +Search." },
};
pub fn find(name: []const u8) ?Setting {
@@ -1052,8 +1070,9 @@ pub const Runtime = struct {
}
},
.shell => {
- const value = std.mem.trim(u8, argument orelse return false, " \t\r\n");
- if (value.len == 0 or !state.shell.requested.set(value)) return false;
+ // Bare, the default again ($SHELL, else /bin/sh).
+ const value = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ if (!state.shell.requested.set(value)) return false;
state.shell.pending = true;
},
.tagline_size => {
@@ -1379,7 +1398,7 @@ pub const Runtime = struct {
try out.writeAll("Startup config: no per-user config path\n");
try out.print(
"Platform: {s}\n" ++
- "Compiled default shell: {s}\n",
+ "Default shell ($SHELL, else /bin/sh): {s}\n",
.{ context.platform, context.compiled_default_shell },
);
if (context.gui_shader_source_mode) |mode|
@@ -1641,6 +1660,7 @@ pub const Runtime = struct {
.choice => |which| switch (which) {
.placement => "pardes",
.boot_shell => "replace",
+ .look_word => "list",
},
.lift => "rim",
.motion => "playful",
@@ -1703,6 +1723,7 @@ pub const Runtime = struct {
.choice => |which| switch (which) {
.placement => "Placement pardes\n",
.boot_shell => "BootShell replace\n",
+ .look_word => "LookWord list\n",
},
.lift => "Lift rim\n",
.motion => "Motion playful\n",
@@ -1738,7 +1759,7 @@ pub const Runtime = struct {
"TaglineSize 82\n",
"Startup config: /tmp/pardes/init\n",
"Platform: gui\n",
- "Compiled default shell: /bin/sh\n",
+ "Default shell ($SHELL, else /bin/sh): /bin/sh\n",
"GUI shader source: live GLSL compiled during this build\n",
"Look hover delay: 18 frames\n",
"Native images: on\n",
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 8f45135a..13cf4488 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -373,6 +373,7 @@ pub const Session = struct {
.pty_signal = ptySignal,
.close_pty = closePaneShell,
.tty_taken = ttyTaken,
+ .fg_name = fgName,
.kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
@@ -544,6 +545,14 @@ pub const Session = struct {
return host_io.ttyTaken(pt.pid, pt.fd);
}
+ fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 {
+ const s = of(ctx);
+ if (pane >= s.ptys.len) return null;
+ const pt = s.ptys[pane];
+ if (pt.fd < 0) return null;
+ return host_io.foregroundName(pt.pid, pt.fd, buf);
+ }
+
fn killJob(ctx: ?*anyopaque, pane: u8) bool {
const s = of(ctx);
if (pane >= s.ptys.len) return false;
@@ -1076,7 +1085,9 @@ pub const Session = struct {
rows = if (rows == 0) c.rows else @min(rows, c.rows);
}
var regridded = false;
- if (cols != 0 and (cols != s.cols or rows != s.rows)) {
+ // Against the core's size too: a root ctl `size` may have set it
+ // while nobody was attached.
+ if (cols != 0 and (cols != s.cols or rows != s.rows or cols != s.core.screen_w or rows != s.core.screen_h)) {
s.cols = cols;
s.rows = rows;
for (&s.clients) |*c| c.need_full = true;
@@ -1190,11 +1201,20 @@ fn onQuitSignal(_: std.posix.SIG) callconv(.c) void {
if (quit_wake >= 0) _ = libc.send(quit_wake, "q", 1, nosignal);
}
+/// The screen of a detached session no frontend has sized.
+pub const detached_cols = 160;
+pub const detached_rows = 50;
+
pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void {
const gpa = init.gpa;
const allocs = pardes.memory.init(gpa);
defer pardes.memory.deinit();
var options = opts;
+ // No frontend yet, so no size to take: one roomy enough for a script's
+ // panes (80x24 left pane/new refused after a few); `size` sets another,
+ // and an attaching frontend its own.
+ options.cols = detached_cols;
+ options.rows = detached_rows;
options.image_allocator = allocs.image;
options.pdf_allocator = allocs.pdf;
options.tree_sitter_allocator = allocs.tree_sitter;
diff --git a/src/dump.zig b/src/dump.zig
index 88a95994..7b27876e 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -848,7 +848,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
restored.command_status = t.status;
}
if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty;
- if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } });
+ if (revive) p.emitSpawn(i, restored.serial, t.cwd);
break :terminal restored;
},
.file => try pardes.panes.File.restore(p, i, src),
@@ -949,3 +949,37 @@ test "a restored pane keeps its dot, as acme's dump keeps a window's" {
try std.testing.expectEqual(@as(u32, 4), dot.q0);
try std.testing.expectEqual(@as(u32, 7), dot.q1);
}
+
+test "a pane restored with unsaved text is asked about by Exit, Restore, Del and get" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("saved\n");
+ _ = ctlfs.pane.spliceBody(p, pane, 0, 0, "unsaved ").?;
+ try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision);
+ try dumpState(p);
+ // Restore reads its dump before it warns: one it can read.
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = p.dump_out.? });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ var restore_line: [4200]u8 = undefined;
+ for ([_][]const u8{ "Exit", "Restore", "Del", "get" }) |word| {
+ const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?);
+ defer restored.deinit();
+ const id = for (restored.panes, 0..) |slot, i| {
+ if (slot) |candidate| if (candidate.file != null and candidate.file.?.output == null) break i;
+ } else return error.FileLost;
+ const th = @import("ninep/testing.zig");
+ const node = if (std.mem.eql(u8, word, "Del") or std.mem.eql(u8, word, "get"))
+ ctlfs.Node.of(restored.panes[id].?.serial, .ctl)
+ else
+ @intFromEnum(ctlfs.TopFile.ctl);
+ const line = if (std.mem.eql(u8, word, "Restore")) try std.fmt.bufPrint(&restore_line, "Restore {s}/d.zon\n", .{dir}) else if (std.mem.eql(u8, word, "Exit")) "Exit\n" else if (std.mem.eql(u8, word, "Del")) "Del\n" else "get\n";
+ const answer = th.wr(restored, node, line);
+ try std.testing.expectEqual(ctlfs.Status.err, answer.reply.status);
+ try std.testing.expect(std.mem.indexOf(u8, answer.reply.ename, "Modified") != null);
+ try std.testing.expect(!restored.quit and restored.panes[id] != null);
+ }
+}
diff --git a/src/exec.zig b/src/exec.zig
index d4480e00..2b0c90cb 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -217,6 +217,28 @@ pub fn heldSelection(p: *Pardes, first: usize) ?[]const u8 {
return null;
}
+/// Where the selection `heldSelection` would give is, as acme's argument
+/// location (exec.c:162, getbytearg): `path:#q0,#q1` of a file pane's dot.
+pub fn heldSelectionPlace(p: *Pardes, first: usize, buf: []u8) ?[]const u8 {
+ var k: usize = 0;
+ while (k < p.panes.len + 2) : (k += 1) {
+ const i = switch (k) {
+ 0 => first,
+ 1 => p.active,
+ else => k - 2,
+ };
+ if (k > 0 and (i == first or (k > 1 and i == p.active))) continue;
+ const t = p.panes[i] orelse continue;
+ if (t.sel[sel_slot].state == .done) return null; // a pointer selection: no byte range kept
+ if ((t.body.vsel.active and t.body.vsel.explicit) or t.body.msel.active) {
+ const f = t.file orelse return null;
+ const d = @import("ninep/pane.zig").dotOf(t);
+ return std.fmt.bufPrint(buf, "{s}:#{d},#{d}", .{ f.path, d.q0, d.q1 }) catch null;
+ }
+ }
+ return null;
+}
+
/// A clicked word and the selection held for it, as one command line.
pub fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 {
const a = std.mem.trim(u8, arg orelse return txt, " \t\r\n");
@@ -249,6 +271,16 @@ pub fn saveFile(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
const f = if (pane.file) |*file| file else return;
if (f.output != null) return; // nothing behind it yet: saveTo, with a path
+ // Its file changed on disk under these edits: once, as acme's Put says
+ // "modified since last read" (exec.c:577), and the next Save writes.
+ if (f.disk_newer != null) {
+ if (!f.disk_newer_warned) {
+ f.disk_newer_warned = true;
+ var said: [Pardes.SavePath.cap + 64]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s} modified on disk since read (Save again to overwrite)", .{f.path}) catch "modified on disk since read (Save again to overwrite)");
+ }
+ f.disk_newer = null;
+ }
p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
}
@@ -310,12 +342,16 @@ pub fn spawnV9fsTty(p: *Pardes, from: usize) void {
}
fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
- const src = p.panes[from] orelse return null;
+ if (p.panes[from] == null) return null;
const free = p.freeSlot() orelse {
p.reportError(from, "Tty", error.NoPaneSlots);
return null;
};
- const nt = p.newShell(free, Pardes.paneDir(src)) catch |err| {
+ const nt = p.newShell(free, p.execDir(from)) catch |err| {
+ if (err == error.PathTooLong) {
+ p.reportFailure(from, std.fmt.comptimePrint("Tty: its directory is longer than a path may be ({d} bytes)", .{pardes.memory.limits.host_path_cap}));
+ return null;
+ }
p.reportError(from, "Tty", err);
return null;
};
@@ -444,13 +480,29 @@ fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed {
}
const repl_enter_wait_ns: u64 = 250 * std.time.ns_per_ms;
+/// Python echoes a long paste over several reads: its Enter waits for the
+/// echo to go quiet this long, not for the first of it.
+const repl_quiet_ns: u64 = 100 * std.time.ns_per_ms;
+
+/// The host's clock now; core time (`now_ns`, `stepped_ns`) is as old as
+/// the last frame, seconds in an idle session.
+fn clockNow(p: *Pardes) u64 {
+ return if (p.host.vtable.now) |clock| clock(p.host.ctx) else p.now_ns;
+}
/// Sends the Enters a paste into pane `id`'s REPL is owed: when the REPL
-/// has answered it (`answered`), or once the wait is up.
+/// has answered it (`answered`), or once the wait is up. Python's answer
+/// only restarts a short wait, so the Enter goes once its echo of the
+/// whole paste is in (a multi-line paste read in part would leave it at
+/// `...`).
pub fn flushReplEnter(p: *Pardes, id: usize, answered: bool) void {
const pane = p.panes[id] orelse return;
if (pane.repl_enter == 0) return;
- if (!answered and p.stepped_ns < pane.repl_enter_due_ns) return;
+ if (answered and (if (pane.repl) |r| r.lang == python_lang else false)) {
+ pane.repl_enter_due_ns = clockNow(p) + repl_quiet_ns;
+ return;
+ }
+ if (!answered and p.now_ns < pane.repl_enter_due_ns) return;
for (0..pane.repl_enter) |_| p.emitWrite(id, "\r");
pane.repl_enter = 0;
}
@@ -492,14 +544,22 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
// A Python block whose last line is indented, or a bare header, is not
// over at one Enter, pasted into 3.13's REPL or typed into the old one:
// the blank line a second Enter gives ends it.
- const enters: u8 = if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n])) 2 else 1;
+ // So is a paste of more than one line: 3.13's REPL takes it as one
+ // input, which one Enter leaves at `...`.
+ const python = if (dst.repl) |r| r.lang == python_lang else false;
+ const lines = std.mem.count(u8, std.mem.trim(u8, clean[0..n], " \t\n"), "\n") + 1;
+ const enters: u8 = if (python and (pythonBlockOpen(clean[0..n]) or (pasted and lines > 1))) 2 else 1;
if (pasted) {
// An Enter read with the paste is part of it: 3.13's REPL takes it
// as a newline in the pasted text, even a one-line one, and waits.
// It goes once the REPL has answered the paste (its next output),
// or after a moment if nothing comes.
dst.repl_enter = enters;
- dst.repl_enter_due_ns = p.stepped_ns + repl_enter_wait_ns;
+ // From the clock now, not core time: idle (a detached session
+ // nobody watches), `stepped_ns` and `now_ns` are as old as the last
+ // frame, and a wait counted from there would be over already, the
+ // Enter going out with the paste.
+ dst.repl_enter_due_ns = clockNow(p) + repl_enter_wait_ns;
} else for (0..enters) |_| p.emitWrite(to, "\r");
// Line by line, a blank line inside a Python block ends the block.
if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
@@ -577,11 +637,14 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
return null;
}
const src = p.panes[from] orelse return null;
- const dir = Pardes.paneDir(src);
+ const dir = p.execDir(from);
// Not one whose pty a job it left behind still prints to: reusing it
// would hang that job up.
- const reuse: ?usize = if (src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| {
+ // From a column's tag, only a finished pane in that column is reused,
+ // as the new one goes in that column (placeNew).
+ const reuse: ?usize = if (p.exec_column == null and src.command != null and src.command_done and !src.command_pty) from else for (p.panes, 0..) |slot, i| {
const other = slot orelse continue;
+ if (p.exec_column) |c| if (if (layout.findPane(p, i)) |f| f.col != c else true) continue;
if (other.command != null and other.command_done and !other.command_pty and std.mem.eql(u8, other.cwdSlice(), dir)) break i;
} else null;
if (reuse) |id| {
@@ -604,7 +667,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l");
panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m");
echoCommand(p, pane, line);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
+ p.emitSpawn(id, pane.serial, pane.cwdSlice());
noteRun(p, pane, "run", line);
return id;
}
@@ -670,6 +733,15 @@ pub fn commandText(txt: []const u8) []const u8 {
return cmd;
}
+/// The builtin a command line's first word names, as acme's lookup reads
+/// only the first word (editors/acme/exec.c, lookup): what an event's
+/// flag 1 says.
+pub fn namesBuiltin(txt: []const u8) bool {
+ const cmd = commandText(txt);
+ const end = std.mem.indexOfAny(u8, cmd, " \t") orelse cmd.len;
+ return std.meta.stringToEnum(Builtin, cmd[0..end]) != null;
+}
+
pub fn executeBuiltinLine(p: *Pardes, id: usize, txt: []const u8) bool {
const cmd = commandText(txt);
if (cmd.len == 0 or p.exec_depth >= max_exec_depth) return false;
@@ -726,12 +798,22 @@ pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?
// 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.
+ // Its log record stands only if it does not fail (events.dropFailedAnnouncement).
+ var announced: ?u64 = null;
if (p.announce and p.settings.verbose and builtins.registry.announces(b)) {
p.setMessage(id, @tagName(b));
pane.msg_announcement = true;
+ announced = pardes.ctlfs.events.newestSaying(p, @tagName(b));
+ p.fs.announced_seq = announced;
+ const word = @tagName(b)[0..@min(@tagName(b).len, p.fs.announced_word.len)];
+ @memcpy(p.fs.announced_word[0..word.len], word);
+ p.fs.announced_word_len = @intCast(word.len);
}
+ const failures = p.fs.failures;
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg, .from_body = from_body };
builtins.registry.dispatch(b, c);
+ if (announced != null and p.fs.failures != failures and p.fs.announced_seq == announced)
+ pardes.ctlfs.events.dropFailedAnnouncement(p, "", true);
}
pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]const u8) void {
@@ -761,6 +843,26 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return;
p.font_request_taken = false;
},
+ // A shell that is no executable is refused, not taken and quietly
+ // replaced at the next spawn; bare, it goes back to the default.
+ .shell => {
+ const want = std.mem.trim(u8, arg orelse "", " \t\r\n");
+ if (want.len == 0) {
+ p.settings.shell.requested.clear();
+ p.settings.shell.pending = true;
+ if (!p.announce) return;
+ var said: [320]u8 = undefined;
+ return p.setMessage(p.active, std.fmt.bufPrint(&said, "Shell: the default again, {s} ($SHELL, else /bin/sh)", .{config.defaultShell()}) catch "Shell: the default again");
+ }
+ if (comptime pardes.hosted) {
+ var why: [320]u8 = undefined;
+ if (@import("host_io.zig").Shell.refusal(want[0..@min(want.len, 200)], &why)) |refused| {
+ var text: [340]u8 = undefined;
+ return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Shell: {s}", .{refused}) catch "Shell: no such shell");
+ }
+ }
+ if (!p.settings.apply(setting, want) and p.announce) p.reportFailure(p.active, "Shell: does not take that value");
+ },
else => if (!p.settings.apply(setting, arg) and p.announce) {
var text: [96]u8 = undefined;
const takes = if (setting.action == .toggle) "takes on or off" else "does not take that value";
@@ -808,19 +910,61 @@ pub const NewPane = enum { scratch, fs, shell, doc, command, errors };
/// t->w == nil).
pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void {
const asked = if (p.exec_column != null) null else origin;
+ layout.compute(p);
+ const before = p.rects;
switch (p.settings.placement) {
.acme => switch (kind) {
// New: the bottom half of the last pane in its own column
// (look.c:921-923, et->col).
.scratch => layout.acmePlace(p, p.exec_column orelse columnOf(p, from), null, id),
- // +Errors, a command's output: the last column (util.c:94-98).
- .command, .errors => layout.acmePlace(p, p.ncol - 1, null, id),
+ // +Errors, a command's output: the last column (util.c:94-98);
+ // a command run from a column's tag, that column.
+ .command => layout.acmePlace(p, p.exec_column orelse p.ncol - 1, null, id),
+ .errors => layout.acmePlace(p, p.ncol - 1, null, id),
// makenewwindow(nil) for 9P's new (acme.c:877), else (t).
.fs => layout.acmePlace(p, acmeColumn(p, from), null, id),
.shell, .doc => layout.acmePlace(p, acmeColumn(p, from), asked, id),
},
.pardes => pardesPlace(p, from, id, kind),
}
+ // No pane is made shorter than its tag and two rows: where the place
+ // chosen has not the room, the column's tallest pane gives it, and
+ // where none can, the pane is refused, and closed at the step's end
+ // (Pardes.unplaced) once whatever made it is done with it.
+ if (!layout.placedRoomy(p, id, &before)) if (layout.findPane(p, id)) |f| {
+ layout.removePane(p, id);
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| p.rects[k] = before[k];
+ layout.snapColWeights(p, f.col);
+ if (!layout.splitTallest(p, f.col, id) or !layout.placedRoomy(p, id, &before)) {
+ if (layout.findPane(p, id) == null) layout.insert(p, f.col, p.col_n[f.col], id);
+ // +Errors goes wherever there is room, last column first; with
+ // none, its maker says the text some other way (appendErrors).
+ if (kind == .errors) {
+ layout.removePane(p, id);
+ var c = p.ncol;
+ while (c > 0) {
+ c -= 1;
+ if (c == f.col) continue;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| p.rects[k] = before[k];
+ layout.snapColWeights(p, c);
+ if (p.col_n[c] == 0) {
+ layout.insert(p, c, 0, id);
+ break;
+ }
+ if (layout.splitTallest(p, c, id) and layout.placedRoomy(p, id, &before)) break;
+ if (layout.findPane(p, id) != null) layout.removePane(p, id);
+ } else {
+ layout.insert(p, f.col, p.col_n[f.col], id);
+ p.unplaced.set(id);
+ return;
+ }
+ } else {
+ p.unplaced.set(id);
+ p.reportError(from, "placement", error.NoPaneRoom);
+ return;
+ }
+ }
+ };
if (kind == .command) return;
tagline.exitHeader(p);
p.active = id;
@@ -865,7 +1009,7 @@ fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void {
.shell => belowParent(p, from, id),
.doc, .errors => pardesDoc(p, from, id),
.command => {
- const rc = p.ncol - 1;
+ const rc = p.exec_column orelse p.ncol - 1;
if (p.col_n[rc] > 0) belowParent(p, p.col_panes[rc][p.col_n[rc] - 1], id) else layout.insert(p, rc, 0, id);
},
}
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 5d998470..d028488e 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -60,6 +60,10 @@ pub const Watch = struct {
kq: c_int = -1,
serial: u32,
generation: Generation,
+ /// The path last read while watched (its hash): its going away later is
+ /// a deletion to say, where a path never there (a pane renamed to a new
+ /// name) is not.
+ existed: ?u64 = null,
};
/// The final slot is the one non-pane watched payload: the active ThemeFile.
@@ -221,8 +225,10 @@ fn markDir(fd: c_int, dir_z: [:0]const u8, watches: *const Table) c_int {
switch (builtin.os.tag) {
.linux => {
// CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE
- // cover rename-over and delete-then-recreate saves.
- const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
+ // cover rename-over and delete-then-recreate saves; DELETE and
+ // MOVED_FROM a file going away, said at once (File.deleted), not
+ // when something else next changes in the directory.
+ const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.DELETE | linux.IN.MOVED_FROM | linux.IN.ONLYDIR;
return libc.inotify_add_watch(fd, dir_z, mask);
},
.macos => {
@@ -440,7 +446,12 @@ pub fn reloadPane(
}
if (pane.file) |file| {
- const bytes = filesystem.read(core, file.path) catch return false;
+ const bytes = filesystem.read(core, file.path) catch |err| {
+ // Gone from under the pane: said once, the text kept.
+ if (err == error.FileNotFound and watched.existed == std.hash.Wyhash.hash(0, file.path)) pardes.panes.File.deleted(core, @intCast(id));
+ return false;
+ };
+ if (watches[id]) |*live| live.existed = std.hash.Wyhash.hash(0, file.path);
defer core.gpa.free(bytes);
const hash = std.hash.Wyhash.hash(0, bytes);
switch (watched.generation) {
@@ -517,6 +528,7 @@ pub fn applyEffect(
const bytes = filesystem.read(core, file.path) catch return false;
defer core.gpa.free(bytes);
watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) };
+ watched.existed = std.hash.Wyhash.hash(0, file.path);
return false;
};
return on and watches[id] != null and reloadPane(core, io, watches, id, false);
@@ -665,6 +677,13 @@ test "restored file watches preserve snapshots and accept later disk changes" {
try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" });
_ = reloadChanged(core, io, gpa, &watches);
+ // A snapshot restored with unsaved text keeps it: a later disk
+ // change is said, never loaded over it (File.changed).
+ if (case.dirty) {
+ try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ try std.testing.expect(pane.file.?.disk_newer != null);
+ continue;
+ }
try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content);
pardes.panes.File.undo(core, pane);
try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
@@ -821,3 +840,36 @@ test "theme watch reloads valid ZON and keeps the last theme across a bad save"
try std.testing.expect(!reloadTheme(core, gpa, &watches, false));
try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg);
}
+
+test "a watched file deleted wakes the watch at once, and its pane says so" {
+ if (comptime !supported or !filesystem.platform_has_fs or builtin.os.tag != .linux) return;
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "here\n" });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ const id: u8 = @intCast(core.freeSlot().?);
+ const pane = try pardes.panes.File.open(core, id, path, 0);
+ const fd = init(true);
+ if (fd < 0) return error.NoWatcher;
+ defer _ = libc.close(fd);
+ var watches: Table = @splat(null);
+ defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 });
+ while (core.nextEffect()) |effect| switch (effect) {
+ .watch => |watch| if (watch.pane == id and watch.on) {
+ _ = applyEffect(core, io, fd, &watches, id, watch.on, watch.mode);
+ },
+ else => {},
+ };
+ _ = drain(fd);
+ try tmp.dir.deleteFile(io, "watched");
+ var pfd = [_]libc.pollfd{.{ .fd = fd, .events = libc.POLL.IN, .revents = 0 }};
+ try std.testing.expect(libc.poll(&pfd, 1, 1000) > 0);
+ _ = reloadChanged(core, io, gpa, &watches);
+ try std.testing.expect(pane.file.?.disk_gone);
+}
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 73a49e9b..bd822b60 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -1,28 +1,24 @@
Pardes control filesystem
=========================
README this guide
-index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (+New if unnamed), column serial
+index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name (<dir>/+New if unnamed), column serial; rsplit(maxsplit=1) first, names hold spaces
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 events: new/del/rename/save <serial> <name>, newcol/delcol <column serial>, run/exit/send <serial> ...,
- ask <serial> <what> <choices>, dump/restore <path>, restored/restoredcol <old> <new>, msg, err
+look write a line: a right click on it at the active pane; read: the serials it touched (this open's)
+exec write a line: a middle click, an editor command word or a shell line; read the same (every exec file)
+log events: new del rename save newcol delcol run exit send ask answer changed dump restore restored restoredcol msg err
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard
-ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word)
-commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it
-layout one line per column: serial index x width current|notcurrent empty|full pane-serials...; then active <serial>
-tag the workspace tag: read it, > replaces it, >> appends (one line)
-col/<n>/tag the tag of the column with serial n, the same way (a serial stays while the column lives)
-col/<n>/ctl Delcol, Joincol, New or Tty on that column; col/<n>/exec a word as a click in its tag; rmdir col/<n> closes an empty one
-pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial
+ctl settings a line; or Newcol, Dump, Restore [f], Exit (asks once), Kill [w], Placement acme|pardes, size C R (--detach)
+commands every builtin: word, arg if it takes one, root|pane|both (the ctl that takes it), -- what it does
+layout a line per column: serial index x width current|notcurrent empty|full pane-serials; active <serial>
+tag the workspace tag, tagexec a word as a click in it; col/<serial>/ a column's tag, ctl (Delcol Joincol New Tty), exec; rmdir: empty only
+pane/new open it to make a pane (<session dir>/+New) in the active column, filling it if empty; the read answers its serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only:
- appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes
+ appended to its directory's +Errors pane) event look exec tagexec, pty/ on terminals; rmdir closes
os/, src/ the host filesystem; the editor's own sources
-Below, $m is the mount: $NINE_MOUNT/pardes/<pid, or NAME of --detach=NAME>; PARDES_MOUNT in a Tty9p shell;
-$NINE_MOUNT itself under `9ns --unix SOCKET -- cmd`, which mounts one session as its root.
+$m below: $NINE_MOUNT/pardes/<pid|NAME>; $NINE_MOUNT under 9ns --unix; PARDES_MOUNT in a Tty9p shell.
cat $m/index which panes exist
n=$(cat $m/pane/new) make a pane, take its serial
@@ -31,21 +27,19 @@ $NINE_MOUNT itself under `9ns --unix SOCKET -- cmd`, which mounts one session as
echo notes.txt > $m/pane/$n/name; echo Save > $m/pane/$n/ctl rename, then save it
echo 'Msg hello' > $m/exec show text in the editor
echo 'Edit ,x/foo/c/bar/' > $m/pane/$n/ctl rename every foo, one undo step (sam's commands)
- cp $m/pane/$n/addr $m/pane/$n/dot; cat $m/pane/$n/sel select the range, read it
exec 3<>$m/log; echo follow >&3; cat <&3 what happened lately, then wait for more
- cat $m/pane/$n/event hold it open to own that pane's clicks
echo Tty > $m/pane/$n/ctl; cat $m/index a terminal in that pane's directory
- exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N then its output; or busy, or error
+ exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 exit N then its output; or busy: <program> is running, or error
printf 'ls\r' > $m/pane/$n/pty/data type into a terminal; pty/status: cols rows busy
echo 'sig INT' > $m/pane/$n/pty/ctl interrupt it; also winsize C R, and exec to restart
Pitfalls, one each:
Each open of pane/new makes another pane (ls, stat, find never do); rmdir closes even a dirty one.
- addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line); > never resets it, 0 does; a miss refuses data.
+ addr: #n,#m, 12:5 or /re/ from the current address (^ $ per line), bytes snapped to runes; 0 resets; a miss refuses data.
data: > replaces the addr range, : > deletes it, a 2nd > inserts (addr moved); body: > empties all.
tag reads the path, then its own text; > replaces that text (default words too), >> appends.
A terminal's body is a history snapshot frozen per open, wrapped rows joined into lines; pty/data is the live stream.
- exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in.
- Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why.
- lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits.
+ exec: a non-builtin line (at most 1024 bytes) runs as a command pane (Shell -c), a shell's typed in; once whole.
+ Through a mount a malformed write is EINVAL, a failed one EIO or an errno that fits (ENOENT): log's err says why.
+ lock/unlock need a held ctl fd (exec 3>ctl); a held lock fails: retry. fs.md has the rest.
Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands.
diff --git a/src/fs.zig b/src/fs.zig
index 8be728a6..4414c150 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1271,6 +1271,16 @@ pub const Namespace = struct {
/// The root ctl is running a session builtin: its messages are the
/// session's, logged `msg -`, though shown on the active pane.
session_write: bool = false,
+ /// A ctl write's failure is being said: its err record logs it, so
+ /// no msg does (Messages.reportFailure).
+ unlogged: bool = false,
+ /// A 9P write of clicks (look, exec, tagexec, a column's exec) is running
+ /// one: a builtin's failure is the write's, as a ctl line's is (no_prompt,
+ /// which also refuses prompts, which a click may open).
+ capturing: bool = false,
+ /// A 9P request is being served (Pardes.serveFs): a prompt it opens is
+ /// a question for the log (`ask`), answered with `answer`.
+ serving: bool = false,
refused: bool = false,
failure: [256]u8 = undefined,
failure_len: u16 = 0,
@@ -1288,6 +1298,19 @@ pub const Namespace = struct {
header_held: bool = false,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
+ /// The log record of the last Verbose announcement (`msg <serial>
+ /// <Word>`) and its word: a failure of that builtin, now or when its
+ /// host work fails (a Save, a Dump), takes it back out of the log, so
+ /// an announcement stands only for a builtin that did what it said.
+ announced_seq: ?u64 = null,
+ /// The serial of the pane the root's exec or look last ran a line at
+ /// (the keyboard's), 0 before one: its err record names that pane.
+ ran_at: u32 = 0,
+ /// Every failure said (Messages.reportFailure), counted: a builtin
+ /// that said one while it ran failed.
+ failures: u32 = 0,
+ announced_word: [32]u8 = undefined,
+ announced_word_len: u8 = 0,
/// A refusal that quotes the message it refuses, as Plan 9's cmderror
/// does (kernel/misc/parse.c:82); answered at once (src/9p_io.zig).
ename: [320]u8 = undefined,
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index f74d5a0a..bd2dca69 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -4097,6 +4097,7 @@ const Shell = struct {
.pty_signal = ptySignal,
.close_pty = closePaneShell,
.tty_taken = ttyTaken,
+ .fg_name = fgName,
.kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
@@ -4559,6 +4560,13 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
return host_io.ttyTaken(pt.pid, pt.fd);
}
+fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 {
+ const s = shellOf(ctx);
+ const pt = s.ptys[pane] orelse return null;
+ if (pt.fd < 0) return null;
+ return host_io.foregroundName(pt.pid, pt.fd, buf);
+}
+
fn killJob(ctx: ?*anyopaque, pane: u8) bool {
const s = shellOf(ctx);
const pt = s.ptys[pane] orelse return false;
diff --git a/src/host_io.zig b/src/host_io.zig
index df78f73c..1db0dac9 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -25,6 +25,9 @@ pub const Host = struct {
pty_signal: ?*const fn (ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void = null,
close_pty: ?*const fn (ctx: ?*anyopaque, pane: u8) void = null,
tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
+ /// The name of the program holding the tty, when it is not the
+ /// shell (host_io.foregroundName), in `buf`.
+ fg_name: ?*const fn (ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 = null,
/// Answered at once, not queued as an effect: Kill asks while the
/// job it saw running is still the one in the tty (host_io.killJob).
kill_job: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
@@ -834,7 +837,7 @@ pub const Shell = struct {
@memcpy(buf[0..bin.len], bin);
buf[bin.len] = 0;
const p: [*:0]const u8 = @ptrCast(buf);
- return if (libc.access(p, X_OK) == 0) p else null;
+ return if (libc.access(p, X_OK) == 0 and !isDirectory(p)) p else null;
}
for (bin_dirs) |dir| {
if (dir.len + bin.len + 1 > buf.len) continue;
@@ -847,6 +850,26 @@ pub const Shell = struct {
return null;
}
+ /// A directory passes access(X_OK) (it can be searched): no shell.
+ pub fn isDirectory(path: [*:0]const u8) bool {
+ const d = libc.opendir(path) orelse return false;
+ _ = libc.closedir(d);
+ return true;
+ }
+
+ /// Why `bin` is no shell, for the words that name one (Tty, Shell);
+ /// null when it is one.
+ pub fn refusal(bin: []const u8, said: []u8) ?[]const u8 {
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ if (find(bin, &buf) != null) return null;
+ if (std.mem.indexOfScalar(u8, bin, '/') != null and bin.len < buf.len) {
+ @memcpy(buf[0..bin.len], bin);
+ buf[bin.len] = 0;
+ if (isDirectory(@ptrCast(&buf))) return std.fmt.bufPrint(said, "not a shell: {s} is a directory", .{bin}) catch "not a shell: a directory";
+ }
+ return std.fmt.bufPrint(said, "no shell \"{s}\" (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell";
+ }
+
fn fallback(buf: *[std.fs.max_path_bytes]u8) [*:0]const u8 {
for (fallbacks) |f| {
@memcpy(buf[0..f.len], f);
@@ -1673,7 +1696,9 @@ fn reapShell(pid: libc.pid_t, grace_ms: u32) void {
/// The tty's foreground job is signalled. A command pane's shell (`command`)
/// is its command too: its line runs with job control on, so the job
/// running is in a group of its own, and the shell's group goes as well, or
-/// the rest of the line would run on after the job it was on.
+/// the rest of the line would run on after the job it was on. A stop (TERM
+/// or KILL) of a command reaches its `&` jobs too, each in a group of its
+/// own in the shell's session: Kill leaves nothing the line started.
pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal, command: bool) void {
const sig = switch (which) {
.int => libc.SIG.INT,
@@ -1683,11 +1708,42 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
.kill => libc.SIG.KILL,
};
const fg = tcgetpgrp(master_fd);
+ if (command and shell_pid > 0 and (which == .term or which == .kill)) signalSession(shell_pid, sig);
if (fg > 0) _ = libc.kill(-fg, sig);
if (command and shell_pid > 0 and fg != shell_pid) _ = libc.kill(-shell_pid, sig);
if (fg <= 0 and !command and shell_pid > 0) _ = libc.kill(shell_pid, sig);
}
+/// Every process group in the session `sid` leads, but its own (which the
+/// caller signals last). forkpty made the shell the session's leader, and
+/// job control moves a job to a new group, never out of the session.
+// ponytail: Linux only (a /proc scan); elsewhere a `&` job outlives Kill,
+// add sysctl KERN_PROC_SESSION on macOS if that matters.
+fn signalSession(sid: libc.pid_t, sig: libc.SIG) void {
+ if (comptime builtin.os.tag != .linux) return;
+ const d = libc.opendir("/proc") orelse return;
+ defer _ = libc.closedir(d);
+ var buf: [4096]u8 = undefined;
+ while (libc.readdir(d)) |ent| {
+ const name = std.mem.sliceTo(&ent.name, 0);
+ const pid = std.fmt.parseInt(libc.pid_t, name, 10) catch continue;
+ var path_buf: [64:0]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&path_buf, "/proc/{d}/stat", .{pid}, 0) catch continue;
+ const ids = parseGroupSession(readProc(path, &buf) orelse continue) orelse continue;
+ if (ids.session == sid and ids.pgrp != sid) _ = libc.kill(-ids.pgrp, sig);
+ }
+}
+
+fn parseGroupSession(stat: []const u8) ?struct { pgrp: libc.pid_t, session: libc.pid_t } {
+ const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null;
+ var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n");
+ _ = fields.next() orelse return null; // 3: state
+ _ = fields.next() orelse return null; // 4: ppid
+ const pgrp = std.fmt.parseInt(libc.pid_t, fields.next() orelse return null, 10) catch return null;
+ const session = std.fmt.parseInt(libc.pid_t, fields.next() orelse return null, 10) catch return null;
+ return .{ .pgrp = pgrp, .session = session };
+}
+
/// Kill: SIGTERM to the job in the tty's foreground, never to the shell.
/// The shell leads its own group, and a job it runs has another; with job
/// control off (`set +m`, a shell run as `sh -c`) the job shares the shell's
@@ -1698,6 +1754,18 @@ pub fn killJob(shell_pid: libc.pid_t, master_fd: c_int) bool {
return libc.kill(-fg, libc.SIG.TERM) == 0;
}
+/// The command name of the tty's foreground job, when that is not the
+/// shell: what `pty/run` says is running. Linux only (/proc/<pgrp>/comm).
+pub fn foregroundName(shell_pid: libc.pid_t, master_fd: c_int, buf: []u8) ?[]const u8 {
+ if (comptime builtin.os.tag != .linux) return null;
+ const fg = tcgetpgrp(master_fd);
+ if (fg <= 0 or fg == shell_pid) return null;
+ var name: [64:0]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/comm", .{@as(u32, @intCast(fg))}, 0) catch return null;
+ const comm = std.mem.trimEnd(u8, readProc(path, buf) orelse return null, "\n");
+ return if (comm.len > 0) comm else null;
+}
+
fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 {
const fd = libc.open(path, .{ .ACCMODE = .RDONLY });
if (fd < 0) return null;
@@ -2076,7 +2144,7 @@ test "a command's exit is its process's, told by its watcher, whether or not its
}
}
-test "a command's background job outlives it and its pty, and Kill stops its whole line" {
+test "a command's background job outlives it and its pty, and Kill stops its whole line, its & jobs too" {
if (comptime !tty_probe_platform) return error.SkipZigTest;
if (!haveFile("/bin/sh")) return error.SkipZigTest;
const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
@@ -2094,6 +2162,8 @@ test "a command's background job outlives it and its pty, and Kill stops its who
.{ .line = "(sleep 0.4; touch left) & exit 0", .kill = false, .made = true },
// The job running is in its own group; the shell goes too.
.{ .line = "sleep 0.4; touch killed", .kill = true, .made = false },
+ // Kill stops the whole command, its `&` jobs as well.
+ .{ .line = "(sleep 0.4; touch killed) & sleep 5", .kill = true, .made = false },
}) |c| {
const id = core.freeSlot().?;
_ = try core.newCommand(id, dir, c.line);
diff --git a/src/layout.zig b/src/layout.zig
index b9b24e79..09cf23b3 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -274,7 +274,11 @@ pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void {
const rb = p.rects[p.col_panes[cc][k + 1]];
const combined: f32 = @floatFromInt(ra.h + rb.h);
var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y);
- nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H));
+ // A terminal keeps its tag and two body rows (floorRows); a text pane
+ // gives up all but its tag, as acme's do.
+ const lo: f32 = @floatFromInt(@max(BOX_H, floorRows(p, p.col_panes[cc][k])));
+ const hi = combined - @as(f32, @floatFromInt(@max(BOX_H, floorRows(p, p.col_panes[cc][k + 1]))));
+ nt = std.math.clamp(nt, @min(lo, @max(@as(f32, BOX_H), hi)), @max(@as(f32, BOX_H), hi));
if (combined <= 0) return;
// Dragging a folded tag open is an explicit resize, so switch this pair
// back to visible-height weights without consuming other folded weights.
@@ -408,6 +412,115 @@ pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
splitAt(p, c, id, y);
}
+/// The body rows a placement leaves every pane it makes or shrinks, under
+/// its tag (acme's minht keeps one).
+pub const min_body_rows = 2;
+
+/// The rows a placement, and a `size`, leave a pane: its tag at its width
+/// and `min_body_rows`. The one minimum both ask.
+pub fn minRows(p: *Pardes, id: usize) u16 {
+ return p.wantedTagRows(id) + min_body_rows;
+}
+
+/// Whether every column has the rows for each of its unfolded panes'
+/// `minRows` (a folded one its tag row): the panes' own minima, not how
+/// the weights happen to share the rows, so a size that fits keeps fitting
+/// when it grows. One pane alone in its column always fits.
+pub fn minimaFit(p: *Pardes) bool {
+ compute(p);
+ const avail = p.screen_h -| (p.topBarHeight() + p.columnBarHeight());
+ for (0..p.ncol) |c| {
+ if (p.col_n[c] < 2) continue;
+ var need: u32 = 0;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ need += if (pane.collapsed) BOX_H else minRows(p, k);
+ }
+ if (need > avail) return false;
+ }
+ return true;
+}
+
+/// After a resize the minima fit: each unfolded pane the resize took below
+/// its `minRows` (it had them in `before`) gets them back, a row at a time
+/// from whichever pane of its column has the most above its own. A pane
+/// dragged short before keeps what it had.
+pub fn restoreMinima(p: *Pardes, before: *const [MAX_PANES]Rect) void {
+ compute(p);
+ for (0..p.ncol) |c| {
+ const n = p.col_n[c];
+ if (n < 2) continue;
+ var hs: [MAX_PANES]u16 = undefined;
+ var mins: [MAX_PANES]u16 = undefined;
+ var short = false;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ const pane = p.panes[k] orelse return;
+ if (pane.collapsed) break;
+ hs[i] = p.rects[k].h;
+ mins[i] = minRows(p, k);
+ if (hs[i] < mins[i] and before[k].h >= mins[i]) short = true;
+ } else {
+ if (!short) continue;
+ for (p.col_panes[c][0..n], 0..) |k, i| {
+ if (hs[i] >= mins[i] or before[k].h < mins[i]) continue;
+ while (hs[i] < mins[i]) {
+ var giver: ?usize = null;
+ var spare: u16 = 0;
+ for (0..n) |j| if (j != i and hs[j] -| mins[j] > spare) {
+ spare = hs[j] - mins[j];
+ giver = j;
+ };
+ const g = giver orelse break;
+ hs[g] -= 1;
+ hs[i] += 1;
+ }
+ }
+ for (p.col_panes[c][0..n], 0..) |k, i| p.panes[k].?.vweight = @floatFromInt(@max(1, hs[i]));
+ }
+ }
+ compute(p);
+}
+
+/// After placing `id`: whether it, and every pane of its column that the
+/// placement shrank, still has its tag and `min_body_rows` (from `before`,
+/// the rects as they were). A pane alone in its column always has room: the
+/// window can be no taller.
+pub fn placedRoomy(p: *Pardes, id: usize, before: *const [MAX_PANES]Rect) bool {
+ compute(p);
+ const f = findPane(p, id) orelse return true;
+ if (p.col_n[f.col] == 1) return true;
+ for (p.col_panes[f.col][0..p.col_n[f.col]]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (pane.collapsed) continue;
+ if (k != id and p.rects[k].h >= before[k].h) continue;
+ if (p.rects[k].h < minRows(p, k)) return false;
+ }
+ return true;
+}
+
+/// Places `id` last in column `c`, where acme's coladd puts a new window,
+/// with half the rows of the column's tallest unfolded pane, when that
+/// leaves both their rows; false, and `id` in no column, when not.
+pub fn splitTallest(p: *Pardes, c: usize, id: usize) bool {
+ compute(p);
+ var tall: ?usize = null;
+ for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (k == id or p.panes[k].?.collapsed) continue;
+ if (tall == null or p.rects[k].h > p.rects[tall.?].h) tall = k;
+ }
+ const v = tall orelse return false;
+ const h = p.rects[v].h;
+ // Its tag wraps at the column's width: measured there, not at none.
+ p.rects[id].w = p.col_w[c];
+ if (h < minRows(p, v) + minRows(p, id)) return false;
+ snapColWeights(p, c);
+ insert(p, c, p.col_n[c], id);
+ const given = std.math.clamp(h / 2, minRows(p, id), h - minRows(p, v));
+ p.panes[v].?.vweight = @floatFromInt(h - given);
+ p.panes[id].?.vweight = @floatFromInt(given);
+ return true;
+}
+
/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the
/// pane that `y` falls in, which keeps its rows above `y`.
fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
@@ -419,9 +532,9 @@ fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
snapColWeights(p, c);
insert(p, c, k + 1, id);
const pane = p.panes[id].?;
- // Each keeps a tag and a body row, as acme's minht does.
- const lo = r.y +| BOX_H + 1;
- const hi = (r.y + r.h) -| (BOX_H + 1);
+ // Each keeps a tag and min_body_rows, where acme's minht keeps one.
+ const lo = r.y +| minRows(p, v);
+ const hi = (r.y + r.h) -| minRows(p, id);
const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse {
pane.vweight = BOX_H + 1;
return;
@@ -550,7 +663,7 @@ pub fn absorbVWeight(p: *Pardes, id: usize, to: ?usize) ?usize {
}
pub fn splitParent(p: *Pardes, want: usize) usize {
- const need = 2 * BOX_H + 3;
+ const need = 2 * (BOX_H + min_body_rows);
if (p.rects[want].h >= need) return want;
if (findPane(p, want)) |f| for (0..p.col_n[f.col]) |k| {
if (p.rects[p.col_panes[f.col][k]].h >= need) return p.col_panes[f.col][k];
@@ -567,23 +680,28 @@ pub fn splitParent(p: *Pardes, want: usize) usize {
pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
const src = p.panes[src_id] orelse return;
const src_h = p.rects[src_id].h;
- const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ // Its tag may wrap to more than one row: the rows under it are its body.
+ const tag_h = @max(BOX_H, src.tag_rows);
+ const body: u16 = if (src_h > tag_h) src_h - tag_h else 1;
const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1;
// cap keep so a content-full source still leaves the new pane a tag +
// a few body rows (an Alt-n from a full shell was born 0 rows tall)
- const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3)));
+ // ...and the source min_body_rows of its own (exec.placeNew), when it
+ // has them: a boot layout splits before any row is laid out.
+ const least = @min(min_body_rows, body);
+ const keep = std.math.clamp(cur, least, @max(least, body -| (BOX_H + 3)));
if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| {
const pid = p.col_panes[f.col][k];
if (p.panes[pid]) |pp| if (pp != nw and !pp.collapsed) {
pp.vweight = @floatFromInt(@max(1, p.rects[pid].h));
};
};
- if (!src.collapsed) src.vweight = @floatFromInt(BOX_H + keep);
- nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep)));
+ if (!src.collapsed) src.vweight = @floatFromInt(tag_h + keep);
+ nw.vweight = @floatFromInt(@max(1, src_h -| (tag_h + keep)));
if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) {
// trimmed: every row ends in a newline, and the empty line after
// the last one is not a result
- const want: f32 = @floatFromInt(BOX_H + panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n")));
+ const want: f32 = @floatFromInt(BOX_H + @max(min_body_rows, panes.File.lineCount(std.mem.trimEnd(u8, f.content, "\n"))));
if (want < nw.vweight) {
src.vweight += nw.vweight - want;
nw.vweight = want;
@@ -727,16 +845,62 @@ pub fn compute(p: *Pardes) void {
if (vsum <= 0) vsum = 1;
var y: u16 = p.topBarHeight() + p.columnBarHeight();
+ const top = y;
const avail_h = p.screen_h -| y;
- for (0..p.col_n[c]) |k| {
+ var hs: [MAX_PANES]u16 = undefined;
+ const n = p.col_n[c];
+ for (0..n) |k| {
const id = p.col_panes[c][k];
- const pane = p.panes[id] orelse continue;
- const lastk = k + 1 == p.col_n[c];
+ const pane = p.panes[id] orelse {
+ hs[k] = 0;
+ continue;
+ };
+ const lastk = k + 1 == n;
const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
const room = p.screen_h -| y;
- const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
- p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
- y +|= ch;
+ hs[k] = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh)))));
+ y +|= hs[k];
+ }
+ keepTerminalFloors(p, c, hs[0..n]);
+ y = top;
+ for (0..n) |k| {
+ const id = p.col_panes[c][k];
+ if (p.panes[id] == null) continue;
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = hs[k] };
+ y +|= hs[k];
+ }
+ }
+}
+
+/// The fewest rows a pane is laid out with: a terminal keeps its tag and
+/// `min_body_rows` (a shorter one loses its prompt and reads busy for
+/// ever), a text pane may shrink to its tag, as acme's windows do.
+fn floorRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return 0;
+ return if (pane.isTerminal()) @max(1, pane.tag_rows) + min_body_rows else 1;
+}
+
+/// Raises each terminal in column `c` below its floor to it, a row at a
+/// time from whichever pane has the most rows above its own floor. Where
+/// the column has not the rows, the terminal keeps what it could get.
+// ponytail: a row per step, O(panes x rows); a column holds few of both.
+fn keepTerminalFloors(p: *Pardes, c: usize, hs: []u16) void {
+ for (hs, 0..) |*h, k| {
+ const floor = floorRows(p, p.col_panes[c][k]);
+ while (h.* < floor) {
+ var giver: ?usize = null;
+ var spare: u16 = 0;
+ for (hs, 0..) |other, j| {
+ if (j == k) continue;
+ const extra = other -| floorRows(p, p.col_panes[c][j]);
+ if (extra > spare) {
+ spare = extra;
+ giver = j;
+ }
+ }
+ const g = giver orelse break;
+ hs[g] -= 1;
+ h.* += 1;
}
}
}
@@ -844,9 +1008,10 @@ test "collapse local transfers remain bounded through repeated tiny window resiz
test "collapse expansion is limited to space available in one neighbor" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 42 });
defer p.deinit();
+ // Text panes: a terminal would keep its tag and two rows (floorRows).
for (1..4) |id| {
- _ = try p.newShell(id, "");
- insert(p, 0, id, id);
+ p.newScratchBelow(0);
+ try std.testing.expectEqual(id, p.col_panes[0][id]);
}
compute(p);
toggleCollapse(p, 2);
diff --git a/src/look.zig b/src/look.zig
index 17746cd1..4ee5a638 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -360,7 +360,12 @@ pub fn resolve(p: ?*pardes.Pardes, word_raw: []const u8, cwd: []const u8, realbu
for (word[config.pane_addr.len..]) |c| {
if (!std.ascii.isDigit(c)) break;
id = id *| 10 +| (c - '0');
- } else return .{ .pane = .{ .id = id, .at = pl.at } };
+ } else {
+ // `@p<serial>`: the pane's serial, as /index and the log name
+ // it, not its slot; one that is not open names none.
+ const slot = if (p) |pp| (pp.paneBySerial(@intCast(@min(id, std.math.maxInt(u32)))) orelse MAX_PANES) else id;
+ return .{ .pane = .{ .id = slot, .at = pl.at } };
+ }
}
for (config.url_schemes) |scheme| {
@@ -635,7 +640,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
const loc: []const u8 = if (has_path)
std.fs.path.basename(pane.file.?.path)
else
- std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{id}) catch return error.PathTooLong;
+ std.fmt.bufPrint(&idbuf, config.pane_addr ++ "{d}", .{pane.serial}) catch return error.PathTooLong;
const cl: usize = @intCast(@max(0, pane.body.cur_row));
const cc: usize = @intCast(@max(0, pane.body.cur_col));
for (lines, 0..) |ln, i| {
@@ -748,6 +753,12 @@ fn lookEdge(delta: i32) LookFrom {
fn lookStand(pane: *Pane) LookFrom {
if (pane.look_at) |s| if (s.row == pane.body.cur_row and s.col0 == pane.body.cur_col)
return .{ .row = s.row, .col = s.col0, .strict = true };
+ // A selection on one row (a Restore brings the dot back as one, with no
+ // walk remembered) is where the walk stands: from its start, past it
+ // either way, as from a spot the walk landed on.
+ const v = pane.body.vsel;
+ if (v.active and v.explicit and v.row == pane.body.cur_row and v.col != pane.body.cur_col)
+ return .{ .row = v.row, .col = @min(v.col, pane.body.cur_col), .strict = true };
return .{ .row = pane.body.cur_row, .col = pane.body.cur_col };
}
@@ -1127,7 +1138,7 @@ fn canonicalLookLocation(p: *Pardes, id: usize, text: []const u8) ?[]const u8 {
if (comptime pdf_enabled) if (pane.pdf) |doc| return locationText(p, doc.path, target.at);
if (pane.image) |iv| return locationText(p, iv.path, target.at);
var path: [32]u8 = undefined;
- return locationText(p, std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{target.id}) catch return null, target.at);
+ return locationText(p, std.fmt.bufPrint(&path, config.pane_addr ++ "{d}", .{pane.serial}) catch return null, target.at);
},
}
}
@@ -1175,14 +1186,35 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v
/// A look that found nothing: said on the message row, logged as an `err`
/// record, and marked so that /look reads back nothing (ninep/ctl.zig run).
+/// The most of a looked-at path or text a miss repeats; past it, cut on a
+/// character boundary and ended with `…`.
+const miss_path_cap = 256;
+
+fn missText(text: []const u8, buf: *[miss_path_cap + "…".len]u8) []const u8 {
+ if (text.len <= miss_path_cap) return text;
+ var n: usize = miss_path_cap;
+ while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
+ @memcpy(buf[0..n], text[0..n]);
+ @memcpy(buf[n..][0.."…".len], "…");
+ return buf[0 .. n + "…".len];
+}
+
+fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ missed(p, id, "{s} has no line {d}", .{ missText(path, &cut), line });
+}
+
fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
p.look_missed = true;
- var said: [160]u8 = undefined;
+ var said: [miss_path_cap + 64]u8 = undefined;
const why = std.fmt.bufPrint(&said, fmt, args) catch "no match";
- var msg: [176]u8 = undefined;
+ var msg: [said.len + 8]u8 = undefined;
+ // Shown on the pane; logged once, as the err below, not a msg too.
+ p.fs.unlogged = true;
p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match");
+ p.fs.unlogged = false;
const serial = if (p.panes[id]) |pane| pane.serial else 0;
- var rec: [200]u8 = undefined;
+ var rec: [said.len + 32]u8 = undefined;
pardes.ctlfs.events.notePath(p, "err", std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return);
}
@@ -1195,7 +1227,7 @@ fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize,
if (at.line > panes.File.nlines(p.gpa, f)) {
if (opened) |free| p.removePane(free, null) catch {};
if (p.panes[before] != null) p.active = before;
- return missed(p, id, "{s} has no line {d}", .{ path, at.line });
+ return missedLine(p, id, path, at.line);
}
if (at.line > 0 and at.col == 0 and at.end_line == 0) {
const start = modal.lineStartOffset(f.content, at.line - 1);
@@ -1205,6 +1237,24 @@ fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize,
}
}
+/// A plain word looked at in a pane, as acme's look3 does with one that
+/// names no file (look.c:210, search): its next place in that pane after
+/// the dot, wrapping to the top, is selected there. No +Search pane
+/// (`LookWord list` lists every place in one instead).
+fn lookWord(p: *Pardes, id: usize, pane: *Pane, word: []const u8) void {
+ const ninep_pane = @import("ninep/pane.zig");
+ const f = pane.file orelse return;
+ const text = f.content;
+ const from: usize = @min(ninep_pane.dotOf(pane).q1, text.len);
+ const at = std.mem.indexOfPos(u8, text, from, word) orelse (std.mem.indexOf(u8, text[0..@min(text.len, from + word.len -| 1)], word) orelse {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return missed(p, id, "no match for \"{s}\"", .{missText(word, &cut)});
+ });
+ clearNavigationSelection(p, pane);
+ ninep_pane.setDot(pane, .{ .q0 = @intCast(at), .q1 = @intCast(at + word.len) });
+ p.active = id;
+}
+
/// `file:addr` or `:addr`, acme's look.c:450-543: a colon followed by an
/// address character (addr.c:28, isaddrc) that is not a line spot this
/// parser already reads (`file:12`, `file:12:5`). Not a URL.
@@ -1216,7 +1266,7 @@ fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } {
// pattern in the address may (`calc.py:/return a/`).
if (std.mem.indexOfAny(u8, tok[0..colon], " \t") != null) return null;
if (std.mem.indexOfAny(u8, expr, " \t") != null and std.mem.indexOfAny(u8, expr, "/?") == null) return null;
- if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/$.#,;", expr[0]) == null) return null;
+ if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/?$.#,;", expr[0]) == null) return null;
if (std.ascii.isDigit(expr[0])) {
const pl = parsePathLine(tok);
if (pl.at.line != 0 and pl.end == tok.len) return null;
@@ -1231,7 +1281,18 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
const ninep_pane = @import("ninep/pane.zig");
const Addr = @import("ninep/addr.zig").Addr;
var target = id;
- if (path.len > 0) {
+ // A pane already open on the path is evaluated where it stands: Look's
+ // own clean start would collapse its dot (and the looker's) before the
+ // address ever read it, so `f:/re/` again would find the same match.
+ var realbuf: [4096]u8 = undefined;
+ const open: ?usize = if (path.len == 0) null else switch (resolveLookTarget(p, id, path, &realbuf)) {
+ .pane => |t| if (t.id < MAX_PANES and p.panes[t.id] != null) t.id else null,
+ else => null,
+ };
+ // `@p<serial>:<address>`: that pane, or, not open, a miss said as one.
+ const by_serial = std.mem.startsWith(u8, path, config.pane_addr);
+ if (by_serial and open == null) return missed(p, id, "no pane {s}", .{path[0..@min(path.len, 32)]});
+ if (open) |o| target = o else if (path.len > 0) {
const before = p.active;
lookAt(p, id, path);
target = p.active;
@@ -1239,8 +1300,11 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
if (target == before and target != id) return;
}
const pane = p.panes[target] orelse return;
- const f = pane.file orelse return;
- if (path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
+ const f = pane.file orelse {
+ if (by_serial and pane.isTerminal()) return lookTerminalAddress(p, id, target, expr);
+ return missed(p, id, "{s} is no text to address", .{path[0..@min(path.len, 64)]});
+ };
+ if (!by_serial and path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
var a: Addr = .{ .text = f.content, .lim = null, .expr = expr };
const r = a.address(ninep_pane.dotOf(pane)) orelse {
return missed(p, id, "{s}", .{a.err});
@@ -1253,6 +1317,46 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
p.active = target;
}
+/// `@p<terminal>:<address>`: any address over the terminal's body as its
+/// logical lines read (the lines a +Search lists as `@p<serial>:<line>`),
+/// from its cursor; the match is selected there, as a +Search row's is.
+fn lookTerminalAddress(p: *Pardes, id: usize, target: usize, expr: []const u8) void {
+ const Addr = @import("ninep/addr.zig").Addr;
+ const pane = p.panes[target].?;
+ const lines = edit.paneCursorLines(p, &pane.body) catch return missed(p, id, "{s}", .{"out of memory"});
+ var text: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ const row: usize = @intCast(@max(0, pane.body.cur_row));
+ var dot: u32 = 0;
+ for (lines, 0..) |ln, i| {
+ if (i == row) dot = @intCast(text.items.len + @min(ln.len, @as(usize, @intCast(@max(0, pane.body.cur_col)))));
+ text.appendSlice(arena, ln) catch return;
+ text.append(arena, '\n') catch return;
+ }
+ var a: Addr = .{ .text = text.items, .lim = null, .expr = expr };
+ const r = a.address(.{ .q0 = dot, .q1 = dot }) orelse return missed(p, id, "{s}", .{a.err});
+ if (a.i < expr.len or r.q0 > r.q1)
+ return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"});
+ const from = spotAt(text.items, r.q0);
+ var at: Spot = .{ .line = from.line, .col = from.col };
+ if (r.q1 > r.q0) {
+ // The last byte of the match, 1-based and inclusive, as a row's range.
+ const to = spotAt(text.items, r.q1 - 1);
+ at.end_line = to.line;
+ at.end_col = to.col;
+ }
+ clearNavigationSelection(p, pane);
+ focusPaneLine(p, target, at, .center);
+}
+
+/// The 1-based line and column of byte `q` of `text`.
+fn spotAt(text: []const u8, q: u32) struct { line: usize, col: usize } {
+ const upto = text[0..@min(q, text.len)];
+ const line = std.mem.count(u8, upto, "\n") + 1;
+ const start = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |nl| nl + 1 else 0;
+ return .{ .line = line, .col = upto.len - start + 1 };
+}
+
pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const pane = p.panes[id] orelse return;
p.noteHaptic(.look);
@@ -1276,18 +1380,31 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// A line past the end of an open pane is a miss (the target's line is
// already clamped, so the one written is asked).
if (found == .pane and found.pane.id < MAX_PANES) if (p.panes[found.pane.id]) |shown| if (shown.file) |*f| if (pl.at.line > panes.File.nlines(p.gpa, f))
- return missed(p, id, "{s} has no line {d}", .{ trimmed, pl.at.line });
- // A miss changes nothing, the selection included; anything else starts
- // from a clean one.
- if (found != .none) clearNavigationSelection(p, pane);
+ return missedLine(p, id, trimmed, pl.at.line);
+ // A miss changes nothing, the selection included; a look that stays in
+ // this pane starts from a clean one. One that goes to another pane
+ // leaves this one's dot alone, as acme's does: only the click's own
+ // highlight goes.
+ if (found == .pane and found.pane.id == id)
+ clearNavigationSelection(p, pane)
+ else if (found != .none) {
+ for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot);
+ pane.sel = @splat(.{});
+ }
switch (found) {
.none => {
- if (pane.image != null or pane.body.mode == .tty) return;
+ // A raw terminal's clicks are its program's; a look written over
+ // 9P is no keyboard input, and searches whatever the key mode.
+ if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return;
+ if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed);
const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| {
p.reportError(id, "search", err);
return;
};
- if (rows == 0) return missed(p, id, "no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)});
+ if (rows == 0) {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return missed(p, id, "no match for \"{s}\"", .{missText(trimmed, &cut)});
+ }
clearNavigationSelection(p, pane);
const at = pane.search_row;
_ = searchStep(p, id, 1);
@@ -1298,8 +1415,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
},
.pane => |t| {
- if (t.id >= MAX_PANES) return;
- const target = p.panes[t.id] orelse return;
+ // `@p<serial>` names no open pane: a miss, said.
+ if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]});
+ const target = p.panes[t.id].?;
const before = p.active;
clearNavigationSelection(p, target);
focusPaneLine(p, t.id, t.at, .center);
@@ -1333,14 +1451,14 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
// touches nothing.
if (openPaneTarget(p, target.path, target.at)) |open| if (p.panes[open.pane.id].?.file) |*f|
if (target.at.line > panes.File.nlines(p.gpa, f))
- return missed(p, id, "{s} has no line {d}", .{ target.path, target.at.line });
+ return missedLine(p, id, target.path, target.at.line);
// focus an existing pane on this path (rescrolled), else open
const before = p.active;
var opened: ?usize = null;
if (!focusPaneByPath(p, target.path, target.at)) {
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
+ return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err);
if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
exec.placeNew(p, id, id, free, .doc);
layout.compute(p);
@@ -1358,7 +1476,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
if (focusPaneByPath(p, target.path, .{})) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
_ = panes.Image.create(p, free, target.path, &.{}) catch |err|
- return p.reportError(id, "look", err);
+ return p.reportError(id, if (@import("Messages.zig").dialReason(err) != null) target.path else "look", err);
exec.placeNew(p, id, id, free, .doc);
},
}
@@ -1587,6 +1705,16 @@ test "Look copies its operand before releasing pointer selection storage" {
try std.testing.expectEqual(.none, p.drag);
}
+test "@p names a pane by its serial, as /index does" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.setTestFile("alpha\nbravo\n");
+ var realbuf: [4096]u8 = undefined;
+ var word: [32]u8 = undefined;
+ const target = resolve(p, try std.fmt.bufPrint(&word, config.pane_addr ++ "{d}:2", .{pane.serial}), "/", &realbuf);
+ try std.testing.expectEqual(p.paneBySerial(pane.serial).?, target.pane.id);
+}
+
test "Look ignores missing and out of bounds pane addresses" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 });
defer p.deinit();
@@ -1596,6 +1724,6 @@ test "Look ignores missing and out of bounds pane addresses" {
lookAt(p, 0, operand);
try std.testing.expectEqual(@as(usize, 0), p.active);
try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row);
- try std.testing.expect(!pane.body.vsel.active);
+ try std.testing.expect(pane.body.vsel.active); // a miss changes nothing, the selection included
}
}
diff --git a/src/modal.zig b/src/modal.zig
index cc7f1ebd..11eda7b9 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -1322,6 +1322,58 @@ pub fn lineEndOffset(text: []const u8, line: usize) usize {
}
/// gap offset -> (row, col) cell
+/// The start of the UTF-8 rune `off` falls in. Addresses (the 9P `addr`,
+/// `dot`, `#n`, `line:col`, a search's match) count runes, as sam's and
+/// acme's do, not grapheme clusters: a lone combining mark or a `\r` is a
+/// place of its own, whatever a terminal draws in one cell.
+pub fn runeStart(text: []const u8, off: usize) usize {
+ const o = @min(off, text.len);
+ if (o == text.len or text[o] & 0xC0 != 0x80) return o;
+ var s = o;
+ var steps: u8 = 0;
+ while (s > 0 and text[s] & 0xC0 == 0x80 and steps < 3) : (steps += 1) s -= 1;
+ // Only a lead byte whose sequence reaches `o`, its bytes all
+ // continuations, makes `o` the inside of a rune; a stray continuation
+ // byte (invalid UTF-8) is a place of its own.
+ const n = std.unicode.utf8ByteSequenceLength(text[s]) catch return o;
+ if (s + n <= o or s + n > text.len) return o;
+ for (text[s + 1 .. s + n]) |c| if (c & 0xC0 != 0x80) return o;
+ return s;
+}
+
+/// `off`, or the end of the rune it falls inside.
+pub fn runeEnd(text: []const u8, off: usize) usize {
+ const o = @min(off, text.len);
+ const s = runeStart(text, o);
+ if (s == o) return o;
+ return s + (std.unicode.utf8ByteSequenceLength(text[s]) catch 1);
+}
+
+/// The rune boundary after the one at `off`.
+pub fn nextRune(text: []const u8, off: usize) usize {
+ return if (off >= text.len) text.len else runeEnd(text, off + 1);
+}
+
+/// The rune boundary before `off`.
+pub fn prevRune(text: []const u8, off: usize) usize {
+ return if (off == 0) 0 else runeStart(text, @min(off, text.len) - 1);
+}
+
+/// `positionAt` snapped to a rune rather than a grapheme cluster.
+pub fn runePositionAt(text: []const u8, off: usize) Cursor {
+ const bounded = runeStart(text, off);
+ const s = if (std.mem.lastIndexOfScalar(u8, text[0..bounded], '\n')) |nl| nl + 1 else 0;
+ return .{ .row = lineAtOffset(text, bounded), .col = bounded - s };
+}
+
+/// `offsetAt` snapped to a rune rather than a grapheme cluster.
+pub fn runeOffsetAt(text: []const u8, c: Cursor) usize {
+ const row = @min(c.row, cursorLineCount(text) - 1);
+ const s = lineStartOffset(text, row);
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return s + runeStart(text[s..e], @min(c.col, e - s));
+}
+
pub fn positionAt(text: []const u8, off: usize) Cursor {
const bounded = @min(off, text.len);
// Find the line start backward without a second scan of the document prefix.
diff --git a/src/mouse.zig b/src/mouse.zig
index 1ca53017..5bfd92ee 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -109,20 +109,23 @@ test "a pane's last row of text is no resize handle; the rule over the next pane
try std.testing.expect(nudgeRightSeam(p, 2) == seam + 2);
}
-test "a pane's grip dragged up or down its own column moves its top, down to a tag alone above" {
+test "a pane's grip dragged up or down its own column moves its top, down to the floor of the pane above" {
if (platform == .web) return;
const p = try cornerFixture(std.testing.allocator);
defer p.deinit();
const upper = p.col_panes[1][0];
const lower = p.col_panes[1][1];
const grip_x = p.rects[lower].x;
- // Up, past the pane above's first row: it keeps one row, its tag.
+ // Up, past the pane above's first row: a text pane keeps its tag, a
+ // terminal its tag and two rows (layout.floorRows).
const top = p.rects[upper].y;
+ const above = p.panes[upper].?;
+ const floor: u16 = if (above.isTerminal()) above.tag_rows + layout.min_body_rows else 1;
p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = grip_x, .row = p.rects[lower].y } });
p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = grip_x, .row = top } });
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = grip_x, .row = top } });
p.sync();
- try std.testing.expectEqual(@as(u16, 1), p.rects[upper].h);
+ try std.testing.expectEqual(floor, p.rects[upper].h);
try std.testing.expectEqual(lower, p.col_panes[1][1]);
// Down, inside itself: its top follows, the pane above taking the rows.
const to = p.rects[lower].y + 6;
@@ -385,6 +388,8 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
// A column tag's command acts on that column, empty or not.
p.exec_column = column;
defer p.exec_column = null;
+ p.exec_header = true;
+ defer p.exec_header = false;
if (word.len > 0) {
if (m.button == config.exec_button) {
const named = std.meta.stringToEnum(Builtin, word);
@@ -709,7 +714,7 @@ fn reportGesture(
text: []const u8,
on_tag: bool,
operand: exec.PointerOperand,
- chorded: bool,
+ chord_arg: ?[]const u8,
) bool {
const scripted = p.panes[id] orelse return false;
if (scripted.fs.readers == 0) return false;
@@ -718,14 +723,29 @@ fn reportGesture(
(if (on_tag) .tag_look else .body_look)
else
(if (on_tag) .tag_exec else .body_exec);
- const named = std.meta.stringToEnum(Builtin, exec.commandText(text)) != null;
+ const named = exec.namesBuiltin(text);
const range = gestureRange(p, id, text, on_tag, operand);
+ // No flag 2: the record already carries the word a click took, its
+ // range and text, and in acme flag 2 promises a second record with them
+ // (editors/acme/exec.c:154-183, look.c:42-68), which none follows here.
var flag: u32 = if (named) ctlfs.events.flag_builtin else 0;
- if (range.q0 == range.q1 and text.len > 0) flag |= ctlfs.events.flag_expansion;
if (is_look) {
if (!named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= ctlfs.events.flag_filename;
- } else if (chorded) flag |= ctlfs.events.flag_chorded;
- return ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text);
+ } else if (chord_arg != null) flag |= ctlfs.events.flag_chorded;
+ if (!ctlfs.events.noteAction(p, id, action, range.q0, range.q1, flag, text)) return false;
+ // A chorded exec's argument follows as two records, its text and where
+ // it came from (`file:#q0,#q1`, empty when that is not known), as acme
+ // sends them (exec.c:182-188); the pane keeps it for the write-back.
+ if (chord_arg) |a| {
+ ctlfs.events.noteFollow(p, id, action, a);
+ var where: [512]u8 = undefined;
+ ctlfs.events.noteFollow(p, id, action, exec.heldSelectionPlace(p, id, &where) orelse "");
+ const pf = &scripted.fs;
+ if (pf.chord_arg) |old| p.gpa.free(old);
+ pf.chord_arg = p.gpa.dupe(u8, a) catch null;
+ pf.chord_at = range;
+ }
+ return true;
}
fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand: exec.PointerOperand) ctlfs.pane.State.Range {
@@ -788,7 +808,7 @@ fn dispatchPointerBuiltin(
// A click that takes no text does nothing, and tells an event reader
// nothing, as acme's look3 and execute return on an empty expansion.
const operand = text orelse return;
- if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg != null)) return;
+ if (gesture) |g| if (reportGesture(p, id, cmd, operand, g.on_tag, g.operand, arg)) return;
// A middle click on a body is the exec a bound REPL may take.
exec.runBuiltinFrom(p, cmd, id, "", exec.withArg(p, operand, arg), gesture != null and !gesture.?.on_tag);
}
@@ -1017,7 +1037,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
if (line >= 0) {
pane.sel[b].state = .none;
var buf: [48]u8 = undefined;
- const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return;
+ const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ pane.serial, @as(usize, @intCast(line)) + 1 }) catch return;
exec.runBuiltin(p, config.look_cmd, s.id, "", target);
return;
}
@@ -1633,3 +1653,50 @@ test "mouse thumb buttons navigate once and clear selection gestures" {
try std.testing.expect(!pane.body.vsel.active);
try std.testing.expectEqual(.none, p.drag);
}
+
+test "a chorded exec an event reader holds is told with its argument, and written back runs with it" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "Msg said\narg-text\n");
+ defer p.deinit();
+ const serial = p.panes[0].?.serial;
+ const event = ctlfs.Node.of(serial, .event);
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = event, .omode = 2 }).reply.handle;
+ // The argument: a selection held in the pane (its second line).
+ ctlfs.pane.setDot(p.panes[0].?, .{ .q0 = 9, .q1 = 17 });
+ p.chord_arg = try p.gpa.dupe(u8, "arg-text");
+ dispatchPointerBuiltin(p, 0, config.exec_cmd, "Msg", .{ .on_tag = false, .operand = .{ .row = 0, .col = 1, .text = "Msg" } });
+ var records: [3][]const u8 = undefined;
+ var got: [3][64]u8 = undefined;
+ for (&records, &got) |*r, *g| {
+ const bytes = th.call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 64 }).bytes;
+ @memcpy(g[0..bytes.len], bytes);
+ r.* = g[0..bytes.len];
+ }
+ try std.testing.expect(std.mem.endsWith(u8, records[0], " 9 3 Msg\n")); // builtin|chorded, no expansion record
+ try std.testing.expectEqualStrings("X0 0 0 8 arg-text\n", records[1][1..]);
+ try std.testing.expect(std.mem.endsWith(u8, records[2], "test.txt:#9,#17\n"));
+ // Written back whole, it runs with its argument.
+ var back: [64]u8 = undefined;
+ const line = try std.fmt.bufPrint(&back, "M{s}", .{records[0][1..]});
+ try std.testing.expectEqual(ctlfs.Status.ok, th.call(p, .{ .tag = 3, .op = .write, .node = event, .handle = h, .data = line }).reply.status);
+ const pane = p.panes[0].?;
+ try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]);
+ // All three records written back as read, in one write or in three:
+ // the two after the first are its argument and where it came from,
+ // never commands of their own.
+ for ([_]bool{ true, false }) |together| {
+ pane.msg_len = 0;
+ var all: [256]u8 = undefined;
+ var w = std.Io.Writer.fixed(&all);
+ for (records) |r| try w.print("M{s}", .{r[1..]});
+ if (together) {
+ _ = th.call(p, .{ .tag = 5, .op = .write, .node = event, .handle = h, .data = w.buffered() });
+ } else for (records) |r| {
+ var one: [80]u8 = undefined;
+ _ = th.call(p, .{ .tag = 6, .op = .write, .node = event, .handle = h, .data = try std.fmt.bufPrint(&one, "M{s}", .{r[1..]}) });
+ }
+ try std.testing.expectEqualStrings("arg-text", pane.msg[0..pane.msg_len]);
+ try std.testing.expect(!th.logHas(p, "run "));
+ }
+ _ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
+}
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index 1e47183b..9498df19 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -13,6 +13,7 @@ fn clip(n: usize) u32 {
pub const e_no_match = "no match for regexp";
pub const e_range = "address out of range";
+pub const e_col_zero = "address out of range: a column counts from 1";
pub const e_regexp = "bad regular expression";
/// Not "took too long": 9ns reads errors by their words, and that would be
/// ENAMETOOLONG.
@@ -94,7 +95,7 @@ pub const Addr = struct {
// pardes's `line:col`, Look's `file:12:5` spelling (look.zig
// parsePathLine): the point at that column of that line, the
// column in bytes from 1, clamped to the line's end and snapped
- // to a character's start as Look's cursor is. Not sam's.
+ // to a rune's start, as every address is. Not sam's.
if (size == .line and dir == 0 and a.i + 1 < a.expr.len and a.expr[a.i] == ':' and
a.expr[a.i + 1] >= '0' and a.expr[a.i + 1] <= '9')
{
@@ -104,10 +105,10 @@ pub const Addr = struct {
col = @min(col * 10 + (a.expr[a.i] - '0'), std.math.maxInt(u32));
const lines = modal.cursorLineCount(a.text);
if (n == 0 or n > lines or col == 0) {
- a.err = e_range;
+ a.err = if (col == 0) e_col_zero else e_range;
return null;
}
- const q = clip(modal.offsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
+ const q = clip(modal.runeOffsetAt(a.text, .{ .row = @intCast(n - 1), .col = @intCast(col - 1) }));
r = .{ .q0 = q, .q1 = q };
continue;
}
@@ -162,7 +163,7 @@ pub const Addr = struct {
a.err = e_range;
return null;
}
- const g = clip(modal.graphemeStart(a.text, @intCast(off)));
+ const g = clip(modal.runeStart(a.text, @intCast(off)));
return .{ .q0 = g, .q1 = g };
}
var line: i64 = n;
@@ -188,35 +189,81 @@ pub const Addr = struct {
while (q0 > 0 and a.text[q0 - 1] != '\n') q0 -= 1;
return .{ .q0 = clip(q0), .q1 = clip(q1) };
},
- '+' => {
- if (q1 > 0) while (q1 < a.text.len and a.text[q1 - 1] != '\n') {
- q1 += 1;
- };
- q0 = q1;
- },
- else => {
- q0 = 0;
- q1 = 0;
- },
+ else => {},
}
- while (line > 0 and q1 < a.text.len) {
- const ch = a.text[q1];
- q1 += 1;
- if (ch == '\n' or q1 == a.text.len) {
- line -= 1;
- if (line > 0) q0 = q1;
+ // Forward, sam's lineaddr (editors/sam/address.c:180-215), not acme's
+ // number(): line n is found by counting newlines, and a line just
+ // past the last newline is an empty one there, so `1` of an empty
+ // text is #0,#0 and `2` of "a\n" is #2,#2; only past that is it out
+ // of range.
+ const nc = a.text.len;
+ var p: usize = 0;
+ var p1: usize = 0;
+ if (n == 0) {
+ if (dir == 0 or q1 == 0) return .{ .q0 = 0, .q1 = 0 };
+ p1 = q1;
+ p = q1 - 1;
+ } else {
+ var k: u32 = 1;
+ if (dir != 0 and q1 > 0) {
+ p = q1 - 1;
+ k = @intFromBool(a.text[p] == '\n');
+ p += 1;
+ }
+ while (k < n) {
+ if (p >= nc) {
+ a.err = e_range;
+ return null;
+ }
+ if (a.text[p] == '\n') k += 1;
+ p += 1;
}
+ p1 = p;
}
- if (line > 0) {
- a.err = e_range;
- return null;
+ while (p < nc) {
+ const ch = a.text[p];
+ p += 1;
+ if (ch == '\n') break;
}
- return .{ .q0 = clip(q0), .q1 = clip(q1) };
+ return .{ .q0 = clip(p1), .q1 = clip(p) };
}
/// acme's regexp(): forward from the end of `r` to the limit, wrapping
/// to the start of the text when there is none; backward, the last
/// match ending by the start of `r`, else the last one in the text.
+ /// The first match at or after `from`, else, with no limit, the first
+ /// from the top (a search wraps); null (the outer) when too slow.
+ fn forward(a: *Addr, rx: *regexp_.Regex, from: usize, hi: usize) ??Range {
+ const ahead = rx.find(a.text, from, hi, hi) catch {
+ a.err = e_slow;
+ return null;
+ };
+ const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse return @as(?Range, null);
+ return @as(?Range, .{ .q0 = clip(m.start), .q1 = clip(m.end) });
+ }
+
+ /// The last match ending at or before `limit`, else the last of all
+ /// (a search wraps); null (the outer) when too slow.
+ fn backward(a: *Addr, rx: *regexp_.Regex, limit: u32) ??Range {
+ var last: ?Range = null;
+ var before: ?Range = null;
+ var at: usize = 0;
+ while (at <= a.text.len) {
+ const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
+ a.err = e_slow;
+ return null;
+ }) orelse break;
+ const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ if (found_range.q1 <= limit) before = found_range;
+ last = found_range;
+ at = if (m.end > m.start) m.end else m.end + 1;
+ }
+ return @as(?Range, before orelse last);
+ }
+
fn regexp(a: *Addr, r: Range, pat: []const u8, back: bool) ?Range {
if (pat.len == 0) {
a.err = "no previous regular expression";
@@ -226,34 +273,29 @@ pub const Addr = struct {
a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
return null;
};
+ // sam's nextmatch (editors/sam/address.c:97-119): an empty match
+ // right at the search's start is no progress, so the search is made
+ // again a character on (back: a character before), wrapping, and
+ // repeated `/^/` or `/$/` walks the lines.
const found = if (back) found: {
- var last: ?Range = null;
- var before: ?Range = null;
- var at: usize = 0;
- while (at <= a.text.len) {
- const m = (rx.find(a.text, at, a.text.len, a.text.len) catch {
- a.err = e_slow;
- return null;
- }) orelse break;
- const found_range: Range = .{ .q0 = clip(m.start), .q1 = clip(m.end) };
- if (found_range.q1 <= r.q0) before = found_range;
- last = found_range;
- at = if (m.end > m.start) m.end else m.end + 1;
- }
- break :found before orelse last;
+ var f = a.backward(&rx, r.q0) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q1 == r.q0) {
+ f = a.backward(&rx, if (r.q0 == 0) clip(a.text.len) else r.q0 - 1) orelse return null;
+ };
+ break :found f;
} else found: {
const hi = if (a.lim) |l| @min(@as(usize, l.q1), a.text.len) else a.text.len;
const from = @min(@as(usize, r.q1), hi);
- const ahead = rx.find(a.text, from, hi, hi) catch {
- a.err = e_slow;
- return null;
+ var f = a.forward(&rx, from, hi) orelse return null;
+ if (f) |m| if (m.q0 == m.q1 and m.q0 == from) {
+ const next = if (from + 1 > hi) (if (a.lim != null) break :found null else 0) else from + 1;
+ f = a.forward(&rx, next, hi) orelse return null;
};
- const m = ahead orelse (if (a.lim != null or from == 0) null else rx.find(a.text, 0, from - 1, hi) catch {
- a.err = e_slow;
- return null;
- }) orelse break :found null;
- break :found Range{ .q0 = clip(m.start), .q1 = clip(m.end) };
+ break :found f;
};
+ // A byte pattern may match inside a rune: the match covers the
+ // runes it touches.
+ if (found) |f| return .{ .q0 = clip(modal.runeStart(a.text, f.q0)), .q1 = clip(modal.runeEnd(a.text, f.q1)) };
return found orelse {
a.err = e_no_match;
return null;
@@ -306,6 +348,27 @@ test "an address is evaluated from the current one, as acme's are" {
try testing.expectEqualStrings(e_order, th.wr(q, qaddr, "/a/,/b/").reply.ename);
}
+test "an empty match at the search's start is passed over, as sam's nextmatch does" {
+ const p = try th.withFile(testing.allocator, "ab\ncd\nef\n");
+ defer p.deinit();
+ const addr = Node.of(th.serialOf(p), .addr);
+ const Case = struct { start: []const u8, expr: []const u8, want: []const u32 };
+ for ([_]Case{
+ // From #3 (the start of line 2), /^/ goes on to line 3.
+ .{ .start = "#3", .expr = "/^/", .want = &.{6} },
+ .{ .start = "#2", .expr = "/$/", .want = &.{ 5, 8 } },
+ .{ .start = "#3", .expr = "/x*/", .want = &.{ 4, 5, 6 } },
+ .{ .start = "#3", .expr = "?^?", .want = &.{0} },
+ }) |c| {
+ _ = th.wr(p, addr, c.start);
+ for (c.want) |q| {
+ try testing.expectEqual(Status.ok, th.wr(p, addr, c.expr).reply.status);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(q, p.panes[0].?.fs.addr.q1);
+ }
+ }
+}
+
test "regular expressions search lines as sam's do, and a search wraps" {
const p = try th.withFile(testing.allocator, "alpha beta\nbeta gamma\ngamma\n");
defer p.deinit();
@@ -357,7 +420,8 @@ test "a failed address leaves none, so data refuses rather than act at the last
try testing.expectEqualStrings(e_no_match, th.wr(p, addr, "/zzz/").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.wr(p, data, "ONE").reply.ename);
try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, data, 0, 64).reply.ename);
- try testing.expectEqualStrings(pane_files.e_addr_failed, th.rd(p, addr, 0, 64).reply.ename);
+ try testing.expectEqual(Status.ok, th.rd(p, addr, 0, 64).reply.status);
+ try testing.expectEqualStrings("", th.rd(p, addr, 0, 64).bytes);
try testing.expectEqual(E.INVAL, th.call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .xdata), .truncate = true }).errno());
try testing.expectEqualStrings("one\ntwo\n", p.panes[0].?.file.?.content);
// A good address gives it one again; truncating addr does not.
@@ -466,7 +530,7 @@ test "the address language, form by form" {
for ([_][2][]const u8{
.{ "zzz", e_syntax }, .{ "/nomatch/", e_no_match }, .{ "99", e_range },
.{ "#999", e_range }, .{ "/a[/", e_regexp }, .{ "/(a/", e_regexp },
- .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_range },
+ .{ "/*a/", e_regexp }, .{ "99:1", e_range }, .{ "2:0", e_col_zero },
}) |c| {
_ = th.wr(p, addr, "#0");
try testing.expectEqualStrings(c[1], th.wr(p, addr, c[0]).reply.ename);
@@ -476,3 +540,31 @@ test "the address language, form by form" {
_ = th.wr(p, addr, "#0");
try testing.expectEqual(E.INVAL, th.wr(p, addr, nested).errno());
}
+
+test "a line just past the last newline is an empty one there, as sam's lineaddr has it" {
+ const Case = struct { text: []const u8, expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .text = "", .expr = "1", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\n", .expr = "2", .q0 = 2, .q1 = 2 },
+ .{ .text = "a\nb", .expr = "2", .q0 = 2, .q1 = 3 },
+ .{ .text = "a\nb\n", .expr = "1", .q0 = 0, .q1 = 2 },
+ .{ .text = "a\nb\n", .expr = "0", .q0 = 0, .q1 = 0 },
+ .{ .text = "a\nb\n", .expr = "1+1", .q0 = 2, .q1 = 4 },
+ .{ .text = "a\nb\n", .expr = "$", .q0 = 4, .q1 = 4 },
+ }) |c| {
+ var a: Addr = .{ .text = c.text, .lim = null, .expr = c.expr };
+ const r = a.address(.{ .q0 = 0, .q1 = 0 }).?;
+ try testing.expectEqual(c.q0, r.q0);
+ try testing.expectEqual(c.q1, r.q1);
+ }
+ for ([_][2][]const u8{ .{ "", "2" }, .{ "a\n", "3" } }) |c| {
+ var a: Addr = .{ .text = c[0], .lim = null, .expr = c[1] };
+ try testing.expectEqual(null, a.address(.{ .q0 = 0, .q1 = 0 }));
+ try testing.expectEqualStrings(e_range, a.err);
+ }
+ // Edit's addresses are these: 1i on an empty file inserts.
+ const p = try th.withFile(testing.allocator, "");
+ defer p.deinit();
+ try testing.expectEqual(Status.ok, th.wr(p, Node.of(th.serialOf(p), .ctl), "Edit 1i/header/\n").reply.status);
+ try testing.expectEqualStrings("header", p.panes[0].?.file.?.content);
+}
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index b6dc7d26..b00ecdba 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -123,8 +123,11 @@ pub fn released(p: *Pardes, serial: ?u32) void {
}
/// A column's ctl: the words that act on a column, as its tag's clicks do --
-/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag.
+/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag. What
+/// they say is the session's in the log (`msg -`), not the focused pane's.
pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
@@ -133,31 +136,59 @@ pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
if (std.mem.eql(u8, w, word)) break true;
} else false;
- if (!takes) return tree.failText(req.tag, E.INVAL, "a column's ctl takes Delcol, Joincol, New or Tty");
+ // Plan 9's words for it, so a mount's errno is EINVAL as the root's
+ // and a pane's ctl give (9ns reads "control message").
+ if (!takes) return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "unknown control message \"{s}\": a column's ctl takes Delcol, Joincol, New or Tty", .{line[0..@min(line.len, 64)]}) catch "unknown control message");
+ // Only Tty takes a word (its shell); `New extra` is no New.
+ if (!std.mem.eql(u8, word, "Tty") and word.len != line.len)
+ return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "wrong #args in control message \"{s}\"", .{line[0..@min(line.len, 64)]}) catch "wrong #args in control message");
const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
p.exec_column = col;
defer p.exec_column = null;
+ p.exec_header = true;
+ defer p.exec_header = false;
if (pardes.ctlfs.ctl.runBuiltin(p, req, p.active, line)) |refusal| return refusal;
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-/// A column's exec: each line run as a middle click on it in that column's
-/// tag, acting on that column.
-pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply {
+/// A column's exec (`serial`), or the workspace tag's (/tagexec, null):
+/// each line run as a middle click on it in that tag, a column's acting on
+/// that column. A read answers the panes the last write touched, as /exec's
+/// does; what the words say is the session's in the log (`msg -`).
+pub fn writeExec(p: *Pardes, req: Req, serial: ?u32) Reply {
+ p.fs.results_len = 0;
+ p.fs.session_write = true;
+ defer p.fs.session_write = false;
var lines = std.mem.splitScalar(u8, req.data, '\n');
while (lines.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
- const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
+ for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, pardes.ctlfs.ctl.e_control);
+ if (pardes.ctlfs.ctl.tooLong(req, line)) |refusal| return refusal;
+ const col = if (serial) |s| layout.columnBySerial(p, s) orelse return Reply.fail(req.tag, E.NOENT) else null;
+ if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT);
p.exec_column = col;
defer p.exec_column = null;
- pardes.ctlfs.ctl.run(p, p.active, true, line);
+ p.exec_header = true;
+ defer p.exec_header = false;
+ if (pardes.ctlfs.ctl.captured(p, req, p.active, true, line, true)) |refusal| return refusal;
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+test "New written to a column's exec makes its scratch in the session's directory, as tagexec's does" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
+ const serial = layout.columnSerial(p, 0);
+ const exec_node = tree.Node.ofCol(serial, .exec);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, exec_node, "New\n").reply.status);
+ try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
+}
+
/// rmdir of a column closes it when it is empty; one with panes is
/// refused, saying so (close them, or Delcol on its ctl, which asks first).
pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply {
@@ -257,10 +288,12 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .ctl), "New\n").reply.status);
try testing.expectEqual(@as(usize, 1), p.col_n[1]);
try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
+ var rec: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}/ctl: unknown control message \"Exit\"", .{empty})));
// rmdir refuses a column with a pane, and says why.
const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
try testing.expectEqual(E.NOTEMPTY, full.errno());
- try testing.expect(th.logHas(p, "column not empty"));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&rec, "err - col/{d}: column not empty", .{empty})));
// exec runs a word as a click in that column's tag: Delcol closes it.
try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
@@ -282,3 +315,76 @@ test "a column's ctl and exec act on it as its tag would, and rmdir closes it on
// The root's ctl points a column word at the column's own.
try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col/<serial>/ctl"));
}
+
+test "tagexec runs a word as a click in the workspace tag; exec files read back what they touched; their messages are the session's" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const tagexec = @intFromEnum(tree.TopFile.tagexec);
+ try testing.expectEqual(tree.Status.ok, th.look_up(p, tree.root, "tagexec").reply.status);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Newcol\n").reply.status);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, tagexec, "Msg from the top\n").reply.status);
+ try testing.expect(th.logHas(p, "msg - from the top\n"));
+ // A column's exec makes a pane there, reads it back, and its Msg is
+ // the session's too.
+ const right = layout.columnSerial(p, 1);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "New\n").reply.status);
+ const made = p.panes[p.col_panes[1][0]].?.serial;
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, Node.ofCol(right, .exec), 0, 64).bytes);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), th.rd(p, tagexec, 0, 64).bytes);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(right, .exec), "Msg from a column\n").reply.status);
+ try testing.expect(th.logHas(p, "msg - from a column\n"));
+}
+
+test "a word run from the workspace's or a column's tag runs in the session's directory, a pane's in its own" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const session = p.sessionDir();
+ try testing.expect(!std.mem.eql(u8, session, pardes.Pardes.paneDir(p.panes[0].?))); // the file is elsewhere
+ const dirs = [_]u64{ @intFromEnum(tree.TopFile.tagexec), Node.ofCol(layout.columnSerial(p, 0), .exec) };
+ for (dirs) |node| {
+ try testing.expectEqual(tree.Status.ok, th.wr(p, node, "true\n").reply.status);
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqualStrings(session, pardes.Pardes.paneDir(p.panes[made].?));
+ try p.removePane(made, null);
+ }
+ const from_pane = p.panes[0].?.serial;
+ _ = th.wr(p, Node.of(from_pane, .exec), "true\n");
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqualStrings(pardes.Pardes.paneDir(p.panes[0].?), pardes.Pardes.paneDir(p.panes[made].?));
+}
+
+test "a command run from a column's tag gets its pane in that column, not the last" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.newColumn(p.active);
+ p.newScratchFor(p.active, .fs); // fills the new, right column
+ p.sync();
+ const left = layout.columnSerial(p, 0);
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(left, .exec), "true\n").reply.status);
+ const made = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expectEqual(@as(usize, 0), layout.findPane(p, made).?.col);
+ // Finished, it is reused only by its own column's commands.
+ p.panes[made].?.command_done = true;
+ p.panes[made].?.command_pty = false;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(layout.columnSerial(p, 1), .exec), "true\n").reply.status);
+ const other = p.paneBySerial(p.fs.results[0]).?;
+ try testing.expect(other != made);
+ try testing.expectEqual(@as(usize, 1), layout.findPane(p, other).?.col);
+}
+
+test "a column's ctl refuses an unknown word and an argument as the other ctls do" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const ctl_node = Node.ofCol(layout.columnSerial(p, 0), .ctl);
+ const unknown = th.wr(p, ctl_node, "Bogus\n");
+ try testing.expectEqual(E.INVAL, unknown.errno());
+ try testing.expect(std.mem.startsWith(u8, unknown.reply.ename, "unknown control message \"Bogus\""));
+ const extra = th.wr(p, ctl_node, "New extra\n");
+ try testing.expectEqual(E.INVAL, extra.errno());
+ try testing.expect(std.mem.startsWith(u8, extra.reply.ename, "wrong #args"));
+ try testing.expectEqual(@as(usize, 1), p.col_n[0]);
+ // A pane's bare answer and a winsize of no size say what they take.
+ try testing.expectEqualStrings("answer takes a choice or -", th.wr(p, Node.of(p.panes[0].?.serial, .ctl), "answer\n").reply.ename);
+}
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 2fdc47cc..4f52d661 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -51,16 +51,19 @@ fn noteResult(p: *Pardes, serial: u32) void {
/// Runs one look or exec from the pane `id`, then records the panes it
/// created; failing that, a look records the pane it focused and an exec
/// the pane it acted on (even one it closed).
-pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
+pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) void {
const context = p.panes[id].?.serial;
// A pane an event reader holds hears its clicks instead of acting on
// them (editors/acme/look.c:53 and exec.c:173 send them to winevent),
// and a line written to a look or exec is a click: on that pane, or for
// the root's, on the pane with the keyboard. The reader writes the
// record back to have it done.
- if (p.panes[id].?.fs.readers > 0) {
- const named = std.meta.stringToEnum(Builtin, exec_line.commandText(text)) != null;
- var flag: u32 = pardes.ctlfs.events.flag_expansion;
+ // A click in a column's or the workspace's tag is not the pane's.
+ if (!in_tag and p.panes[id].?.fs.readers > 0) {
+ const named = exec_line.namesBuiltin(text);
+ // No flag 2: a written line was never expanded from a click, and no
+ // expansion record follows (editors/acme/look.c:42-43, exec.c:154-157).
+ var flag: u32 = 0;
if (named) flag |= pardes.ctlfs.events.flag_builtin;
if (!exec and !named and std.mem.indexOfAny(u8, text, "/.:") != null) flag |= pardes.ctlfs.events.flag_filename;
_ = pardes.ctlfs.events.noteAction(p, id, if (exec) .body_exec else .body_look, 0, 0, flag, text);
@@ -79,9 +82,8 @@ pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
- // A look's hits buffer is how it found the text, not where: it
- // answers the pane the hit is selected in.
- if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue;
+ // A look that made a +Search pane (LookWord list, a terminal)
+ // answers it: that is where the places it found are.
noteResult(p, serial);
created = true;
};
@@ -112,34 +114,77 @@ const Messages = struct {
const edit = "Edit";
if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t'))
return first;
- const sam = @import("../sam_edit.zig");
- while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) {
- end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len;
- m.at = end + 1;
- }
+ end = editEnd(m.p, m.data, start, end, true).?;
+ m.at = end + 1;
return std.mem.trim(u8, m.data[start..end], " \t\r");
}
};
+/// Where the Edit block whose first line is `data[start..first_end]` ends:
+/// the end of its last line, taking the lines after it while its `{`
+/// group or a/c/i text block is open. Out of lines, the end of `data` when
+/// `to_end` (a write run whole), else null (wait for more). Only a line
+/// that could end what is open is parsed again with all before it: within
+/// a text block, just a `.` line. The arena copy of each parse is freed.
+fn editEnd(p: *Pardes, data: []const u8, start: usize, first_end: usize, to_end: bool) ?usize {
+ const sam = @import("../sam_edit.zig");
+ const edit = "Edit";
+ const arena = p.scratch.allocator();
+ var end = first_end;
+ var wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]);
+ while (wait != .none) {
+ if (end >= data.len) return if (to_end) data.len else null;
+ const next = std.mem.indexOfScalarPos(u8, data, end + 1, '\n') orelse
+ (if (to_end) data.len else return null);
+ const line = std.mem.trim(u8, data[end + 1 .. next], " \t\r");
+ end = next;
+ if (wait == .text and !std.mem.eql(u8, line, ".")) continue;
+ wait = sam.waitsFor(arena, std.mem.trim(u8, data[start..end], " \t\r")[edit.len..]);
+ }
+ return end;
+}
+
+/// How much of `data` is whole messages, as `Messages` reads them: lines
+/// ended by a newline, an Edit block only once it closes. The rest waits
+/// for more (tree.Open.pending).
+pub fn completeEnd(p: *Pardes, data: []const u8) usize {
+ const edit = "Edit";
+ var whole: usize = 0;
+ var at: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, data, at, '\n')) |nl| {
+ const start = at;
+ var end = nl;
+ const first = std.mem.trim(u8, data[start..end], " \t\r");
+ if (std.mem.startsWith(u8, first, edit) and (first.len == edit.len or first[edit.len] == ' ' or first[edit.len] == '\t'))
+ end = editEnd(p, data, start, end, false) orelse return whole;
+ at = end + 1;
+ whole = at;
+ }
+ return whole;
+}
+
// ---- look and exec ----
/// 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 {
+pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Reply {
+ p.fs.ran_at = 0;
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var it = Messages.init(p, req.data);
while (it.next()) |text| {
if (text.len == 0) continue;
// Only an Edit block holds newlines (Messages).
- for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL);
+ for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return tree.failText(req.tag, E.INVAL, e_control);
+ if (exec) if (tooLong(req, text)) |refusal| return refusal;
if (!apply) continue;
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);
+ p.fs.ran_at = p.panes[id].?.serial;
p.fs.no_pane_slot = false;
- run(p, id, exec, text);
+ if (captured(p, req, id, exec, text, in_tag)) |refusal| return refusal;
// A pane it would have opened had no slot: the write fails, and
// a look reads back nothing.
if (p.fs.no_pane_slot) {
@@ -151,6 +196,38 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+pub const e_too_long = std.fmt.comptimePrint("a command line is at most {d} bytes", .{pardes.exec.command_max});
+
+/// A line an exec would run as a command, over the most a command line may
+/// be: refused whole, before anything runs, rather than said on the message
+/// row after the write succeeded. A builtin's line (Msg, an Edit block) may
+/// be longer.
+pub fn tooLong(req: Req, line: []const u8) ?Reply {
+ if (line.len <= pardes.exec.command_max) return null;
+ const cmd = exec_line.commandText(line);
+ const word = cmd[0 .. std.mem.indexOfAny(u8, cmd, " \t+") orelse cmd.len];
+ if (std.meta.stringToEnum(Builtin, word) != null) return null;
+ return tree.failText(req.tag, E.INVAL, e_too_long);
+}
+
+pub const e_control = "a command line holds no control character but a tab";
+
+/// Runs one click (`run`) as a 9P write's: a builtin that fails there fails
+/// the write, with its words and an err record, and no msg, as a ctl line
+/// does (runBuiltin). The refusal, or null.
+pub fn captured(p: *Pardes, req: Req, id: usize, exec: bool, text: []const u8, in_tag: bool) ?Reply {
+ p.fs.capturing = true;
+ p.fs.failure_len = 0;
+ defer p.fs.capturing = false;
+ run(p, id, exec, text, in_tag);
+ if (p.fs.failure_len == 0) return null;
+ // A failed click answers nothing: look reads back empty.
+ p.fs.results_len = 0;
+ const failure = p.fs.failure[0..p.fs.failure_len];
+ // A slot or a place refused keeps its errno words (no space: ENOSPC).
+ return tree.failText(req.tag, E.IO, std.fmt.bufPrint(&p.fs.ename, "{s}", .{failure}) catch failure);
+}
+
pub fn resultsLen(p: *Pardes) u64 {
var n: u64 = 0;
for (p.fs.results[0..p.fs.results_len]) |serial| {
@@ -160,11 +237,28 @@ pub fn resultsLen(p: *Pardes) u64 {
return n;
}
+/// The panes the open's last write touched (tree.Open.results); with no
+/// open, the session's last.
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|
+ const o = tree.openOf(p, req);
+ const kept = if (o) |open| open.results.list[0..open.results.len] else p.fs.results[0..p.fs.results_len];
+ // A look answers panes to go to: never one closed since (an exec may
+ // answer the pane it closed, which is what it acted on).
+ const looking = if (tree.Node.target(req.node)) |t| switch (t) {
+ .top => |f| f == .look,
+ .pane => |pt| pt.file == .look,
+ .col => false,
+ } else false;
+ for (kept) |serial| if (!looking or p.paneBySerial(serial) != null)
out.print(p.gpa, "{d}\n", .{serial}) catch return Reply.fail(req.tag, E.NOMEM);
- return tree.stagedReply(p, req);
+ const open = o orelse return tree.stagedReply(p, req);
+ if (!open.results.wrote) return tree.stagedReply(p, req);
+ var at = req;
+ at.off = open.results.read;
+ const reply = tree.stagedReply(p, at);
+ open.results.read += reply.payload.staged;
+ return reply;
}
// ---- /status ----
@@ -366,7 +460,7 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
.theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }),
.shell => {
const chosen = s.shell.requested.get();
- try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.default_shell });
+ try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.defaultShell() });
},
.font => {
const requested = s.font.requested_name.get();
@@ -424,6 +518,30 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
var it = Messages.init(p, req.data);
while (it.next()) |line| {
if (line.len == 0) continue;
+ if (std.mem.startsWith(u8, line, "size ") or std.mem.eql(u8, line, "size")) {
+ const size = sizeOf(line) orelse return refuse(p, req, e_size, line);
+ // A frontend's window is the size; only a session none is
+ // attached to (--detach, unattended) takes one written.
+ if (!p.unattended) return refuse(p, req, "size: a frontend is attached and owns the size", line);
+ if (apply) {
+ const was: [2]u16 = .{ p.screen_w, p.screen_h };
+ const before = p.rects;
+ p.update(.{ .resize = .{ .cols = size[0], .rows = size[1] } });
+ p.sync();
+ // Every pane keeps its tag and two rows, as placement
+ // leaves them (layout.minRows, the one minimum): a size
+ // too small for the panes' minima is refused, and one
+ // they fit gives back rows the resize took under them.
+ if (!pardes.layout.minimaFit(p)) {
+ p.update(.{ .resize = .{ .cols = was[0], .rows = was[1] } });
+ p.sync();
+ return refuse(p, req, "size: too small for the panes, each its tag and 2 rows", line);
+ }
+ pardes.layout.restoreMinima(p, &before);
+ p.sync();
+ }
+ continue;
+ }
if (!apply) {
if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
continue;
@@ -437,17 +555,88 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
+const size_min = [2]u16{ 20, 6 };
+const size_max = [2]u16{ 4096, 4096 };
+// "invalid": the word 9ns reads as EINVAL (enameToErrno), as winsize's.
+const e_size = std.fmt.comptimePrint("invalid size: takes <cols> <rows>, at least {d}x{d}, at most {d}x{d}", .{ size_min[0], size_min[1], size_max[0], size_max[1] });
+
+/// `size <cols> <rows>`, within size_min and size_max.
+fn sizeOf(line: []const u8) ?[2]u16 {
+ var it = std.mem.tokenizeAny(u8, line["size".len..], " \t");
+ const cols = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
+ const rows = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
+ if (it.next() != null or cols < size_min[0] or rows < size_min[1] or cols > size_max[0] or rows > size_max[1]) return null;
+ return .{ cols, rows };
+}
+
// ---- /commands ----
/// Every builtin the registry holds, one a line: its word, `arg` when it
/// takes one, and which ctl takes it, `root` or `pane` -- the registry
/// itself, so a new builtin is listed the moment it exists.
+/// A builtin's doc comment, its first sentence, from the source itself: a
+/// line of /commands says what each word does without a second copy of it.
+fn summaryOf(word: []const u8, buf: []u8) []const u8 {
+ const source = @embedFile("../builtins.zig");
+ var needle_buf: [64]u8 = undefined;
+ const needle = std.fmt.bufPrint(&needle_buf, "\npub const {s} = struct", .{word}) catch return "";
+ const at = std.mem.indexOf(u8, source, needle) orelse return "";
+ // Back over the `///` lines just above it.
+ var start = at + 1;
+ while (start > 0) {
+ const prev_end = start - 1;
+ const prev_start = if (std.mem.lastIndexOfScalar(u8, source[0..prev_end], '\n')) |nl| nl + 1 else 0;
+ if (!std.mem.startsWith(u8, std.mem.trimStart(u8, source[prev_start..prev_end], " "), "///")) break;
+ start = prev_start;
+ }
+ var w = std.Io.Writer.fixed(buf);
+ var lines = std.mem.splitScalar(u8, source[start .. at + 1], '\n');
+ while (lines.next()) |line| {
+ const text = std.mem.trim(u8, std.mem.trimStart(u8, std.mem.trimStart(u8, line, " "), "/"), " ");
+ if (text.len == 0) continue;
+ if (w.buffered().len > 0) w.writeByte(' ') catch break;
+ w.writeAll(text) catch break;
+ }
+ const all = stripCitations(w.buffered());
+ // The first sentence, whole: to a `.` that ends one.
+ var i: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, all, i, '.')) |dot| : (i = dot + 1) {
+ if (dot + 1 == all.len or all[dot + 1] == ' ') return all[0 .. dot + 1];
+ }
+ return all;
+}
+
+/// Drops a parenthesis that cites source (`(editors/acme/exec.c:892)`,
+/// `(exec.c, \`kill\`; acme.c's WKill)`): a reader of /commands wants what
+/// the word does, not where acme has it. In place; answers the rest.
+fn stripCitations(text: []u8) []u8 {
+ var out: usize = 0;
+ var i: usize = 0;
+ while (i < text.len) {
+ if (text[i] == '(') if (std.mem.indexOfScalarPos(u8, text, i, ')')) |close| {
+ const inside = text[i + 1 .. close];
+ if (std.mem.indexOf(u8, inside, ".c") != null or std.mem.indexOf(u8, inside, ".zig") != null) {
+ // With the space before it, and a colon after it kept.
+ if (out > 0 and text[out - 1] == ' ') out -= 1;
+ i = close + 1;
+ continue;
+ }
+ };
+ text[out] = text[i];
+ out += 1;
+ i += 1;
+ }
+ return text[0..out];
+}
+
fn writeCommands(w: *std.Io.Writer) !void {
for (std.enums.values(Builtin)) |b| {
+ // Edit is a pane's word the root's ctl takes too, at the active pane.
+ const scope: []const u8 = if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane";
try w.print("{s}{s} {s}", .{
@tagName(b),
if (builtins.registry.takesArg(b)) " arg" else "",
- if (builtins.registry.scope(b) == .session) "root" else "pane",
+ scope,
});
// A setting that chooses among words: the words, comma-joined. Bare,
// it steps to the next of them (a two-valued one flips).
@@ -457,6 +646,14 @@ fn writeCommands(w: *std.Io.Writer) !void {
var first = true;
while (parts.next()) |v| : (first = false) try w.print("{s}{s}", .{ if (first) "" else ",", v });
};
+ // Then ` -- ` and what it does: a setting's doc, else its source's.
+ var summary_buf: [2048]u8 = undefined;
+ const summary = if (config.Runtime.find(@tagName(b))) |setting| setting.doc else summaryOf(@tagName(b), &summary_buf);
+ if (summary.len > 0) {
+ // The sentence whole unless it is long, then cut between words.
+ const kept = @import("../Messages.zig").clip(summary, 240);
+ try w.print(" -- {s}{s}", .{ kept, if (kept.len < summary.len) "…" else "" });
+ }
try w.writeByte('\n');
}
}
@@ -550,9 +747,21 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
if (std.mem.eql(u8, line, "get")) {
if (!apply) continue;
- if (getRefused(p, pane)) |said| return tree.failText(req.tag, E.IO, said);
+ // Quoting the line, as a builtin's refusal (Save's) does.
+ if (getRefused(p, pane)) |said_in_ename| {
+ // refuse writes ename, where the words are: copy them out.
+ var said_buf: [320]u8 = undefined;
+ const said = said_buf[0..said_in_ename.len];
+ @memcpy(said, said_in_ename);
+ const r = refuse(p, req, said, line);
+ return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = r.ename };
+ }
const errno = get(p, pane);
- if (errno != 0) return Reply.fail(req.tag, errno);
+ // Naming the path, in words 9ns reads as the errno.
+ if (errno != 0) {
+ const path = if (pane_files.fileOf(pane)) |f| f.path else "";
+ return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (errno == E.NOENT) "no such file" else "cannot be read" }) catch "get: no such file");
+ }
} else if (std.mem.eql(u8, line, "lock")) {
if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl);
if (other) return tree.failText(req.tag, E.BUSY, e_locked);
@@ -562,6 +771,24 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked);
held = false;
if (apply) pane.fs.lock = null;
+ } else if (std.mem.eql(u8, line, "answer")) {
+ // With a question up, what it offers.
+ var offered: [64]u8 = undefined;
+ if (p.questionChoices(p.paneBySerial(serial).?, &offered)) |choices| {
+ var why: [96]u8 = undefined;
+ return tree.failText(req.tag, E.INVAL, std.fmt.bufPrint(&p.fs.ename, "answer takes {s} or -", .{choices}) catch std.fmt.bufPrint(&why, "answer takes a choice or -", .{}) catch "answer takes a choice or -");
+ }
+ return tree.failText(req.tag, E.INVAL, "answer takes a choice or -");
+ } else if (std.mem.startsWith(u8, line, "answer ") and p.panes[p.paneBySerial(serial).?].?.prompt == .save) {
+ // Save's path, asked over 9P (`ask <serial> save path`).
+ if (!apply) continue;
+ const id = p.paneBySerial(serial).?;
+ const asked = p.panes[id].?;
+ const path = std.mem.trim(u8, line["answer ".len..], " ");
+ var rec: [300]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {s}", .{ serial, path[0..@min(path.len, 256)] }) catch "");
+ if (!std.mem.eql(u8, path, "-")) exec_line.saveTo(p, id, path);
+ asked.closePrompt();
} else if (std.mem.startsWith(u8, line, "answer ")) {
// The pane's question (its `ask` in the log), answered.
const choice = std.mem.trim(u8, line["answer ".len..], " ");
@@ -585,7 +812,7 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
-const e_not_locked = "window not locked by this open";
+const e_not_locked = "pane not locked by this open";
/// Who holds it is another open of this ctl, whose client pardes cannot name.
const e_locked = tree.e_in_use ++ ": another open of this ctl holds the lock";
@@ -600,6 +827,9 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 {
if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null;
pane.discard_warned = .{ .revision = f.revision, .by = .get };
const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)";
+ // Shown; the write's err record logs it, not a msg too.
+ p.fs.unlogged = true;
+ defer p.fs.unlogged = false;
p.setMessage(p.paneBySerial(pane.serial).?, said);
return said;
}
@@ -611,10 +841,19 @@ fn get(p: *Pardes, pane: *Pane) u16 {
error.FileNotFound => E.NOENT,
else => E.IO,
};
+ // Throwing unsaved edits away for it is a reload the log hears of, as a
+ // clean one from the watch is.
+ const discarded = f.revision != f.saved_revision;
panes.File.pushUndo(p, pane);
panes.File.setContent(p, f, bytes);
f.saved_revision = f.revision;
f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.disk_newer = null;
+ f.disk_gone = false;
+ if (discarded) {
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch "");
+ }
return 0;
}
@@ -671,12 +910,13 @@ test "commands lists every builtin with its argument and the ctl that takes it"
var words = std.mem.tokenizeScalar(u8, line, ' ');
const b = std.meta.stringToEnum(Builtin, words.next().?).?;
if (builtins.registry.takesArg(b)) try testing.expectEqualStrings("arg", words.next().?);
- try testing.expectEqualStrings(if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?);
+ try testing.expectEqualStrings(if (b == .Edit) "both" else if (builtins.registry.scope(b) == .session) "root" else "pane", words.next().?);
if (config.Runtime.find(@tagName(b))) |s| if (config.Runtime.choices(s.action) != null) try testing.expect(words.next() != null);
- try testing.expect(words.next() == null);
+ // Then, where the source has one, ` -- ` and what it does.
+ if (words.next()) |dashes| try testing.expectEqualStrings("--", dashes);
}
try testing.expectEqual(std.enums.values(Builtin).len, lines);
- for ([_][]const u8{ "\nDel arg pane\n", "\nNewcol root\n", "\nSave arg pane\n", "\nVerbose arg root on,off\n", "\nPlacement arg root acme,pardes\n", "\nUndo pane\n", "\nRedo pane\n" }) |row|
+ for ([_][]const u8{ "\nDel arg pane -- ", "\nNewcol root -- ", "\nSave arg pane", "\nVerbose arg root on,off", "\nPlacement arg root acme,pardes", "\nUndo pane -- ", "\nEdit arg both -- acme's Edit" }) |row|
try testing.expect(std.mem.indexOf(u8, listed.bytes, row) != null);
}
@@ -691,6 +931,15 @@ test "Undo and Redo on a pane's ctl step its body through its edits" {
try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
try testing.expectEqualStrings("one\ntwo\n", rd(p, body, 0, 64).bytes);
+ // Nothing left: said, and the write still succeeds, as acme's is silent.
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expect(th.logHas(p, "Redo: nothing to redo"));
+ _ = wr(p, Node.of(serial, .ctl), "Undo");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expect(th.logHas(p, "Undo: nothing to undo"));
+ // Said again word for word, it is that line counted, as err's are.
+ for (0..3) |_| _ = wr(p, Node.of(serial, .ctl), "Undo");
+ try testing.expect(th.logHas(p, "Undo: nothing to undo (x4)\n"));
}
test "focus reads the pane with the keyboard, and a serial written to it moves the keyboard there" {
@@ -932,8 +1181,8 @@ test "a message from a root ctl write is the session's in the log" {
const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 8192 }).bytes.len;
_ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Kill\n").reply.status);
- // The builtin's name, then what it said: both the session's.
- try testing.expectEqualStrings("msg - Kill\n", call(p, .{ .tag = 4, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
+ // What it said, the session's; its own words name it, so its Verbose
+ // announcement gives way to them.
try testing.expectEqualStrings("msg - Kill: nothing running\n", call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).bytes);
}
@@ -980,6 +1229,37 @@ test "a look takes acme's :addr and file:addr, and a miss changes nothing" {
try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: ");
}
+test "the root look's file:/re/ goes on from the dot, and a miss leaves the dot as it was" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "foo x foo y\nfoo\n");
+ defer p.deinit();
+ const dot = Node.of(serialOf(p), .dot);
+ const root = @intFromEnum(tree.TopFile.look);
+ var line: [256]u8 = undefined;
+ const path = p.panes[0].?.file.?.path;
+ const again = try std.fmt.bufPrint(&line, "{s}:/foo/\n", .{path});
+ for ([_][]const u8{ " 0 3 ", " 6 9 ", " 12 15 ", " 0 3 " }) |want| {
+ try testing.expectEqual(Status.ok, wr(p, root, again).reply.status);
+ try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes);
+ }
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "{s}:/zzqq/\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
+}
+
+test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an ellipsis" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root = @intFromEnum(tree.TopFile.look);
+ const long = "some/deeply/nested/directory/that/goes/on/and/on/with/a_rather_long_file_name.txt";
+ var line: [512]u8 = undefined;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "{s}\n", .{long}));
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&line, "look: no match for \"{s}\"\n", .{long})));
+ const huge = "é" ** 150; // 300 bytes: cut at 256, between characters
+ _ = wr(p, root, huge ++ "\n");
+ try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 128 ++ "…\"\n"));
+}
+
test "exec runs a builtin at the pane and records the pane it acted on" {
const gpa = testing.allocator;
const p = try withFile(gpa, "Msg fs-ran\n");
@@ -1133,13 +1413,16 @@ test "get reloads the pane from disk and reports a missing file without losing d
try testing.expectEqualStrings("from disk\n", pane.file.?.content);
try testing.expect(!pane_files.dirtyOf(pane));
try testing.expect(pane.file.?.history.undo_len > 0);
+ // What it had (renamed, so dirty) was thrown away for it: logged.
+ var reloaded: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
_ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory}));
_ = wr(p, Node.of(serial, .body), "unsaved\n");
// Unsaved text is asked about once, as acme's get asks winclean.
const asked = wr(p, ctl_node, "get\n");
try testing.expectEqual(E.IO, asked.errno());
- try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard)"));
+ try testing.expect(std.mem.endsWith(u8, asked.reply.ename, ": Modified (get again to discard) \"get\""));
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);
@@ -1174,16 +1457,17 @@ test "a pane's own look and exec go to the event reader holding it, which writes
const pane = p.panes[0].?;
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") == null);
const record = call(p, .{ .tag = 2, .op = .read, .node = event, .handle = h, .size = 256 }).bytes;
- try testing.expectEqualStrings("FX0 0 2 10 Msg routed\n", record);
+ // Flag 1: its first word is a builtin; no 2, no expansion follows.
+ try testing.expectEqualStrings("FX0 0 1 10 Msg routed\n", record);
// Written back, it is done.
try testing.expectEqual(tree.Status.ok, wr(p, event, record).reply.status);
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "routed") != null);
try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .look), "one.txt:3\n").reply.status);
- try testing.expectEqualStrings("FL0 0 6 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ try testing.expectEqualStrings("FL0 0 4 9 one.txt:3\n", call(p, .{ .tag = 3, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
// The root's is a click at the pane with the keyboard, this one.
try testing.expectEqual(tree.Status.ok, wr(p, root_exec, "Msg rooted\n").reply.status);
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "rooted") == null);
- try testing.expectEqualStrings("FX0 0 2 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
+ try testing.expectEqualStrings("FX0 0 1 10 Msg rooted\n", call(p, .{ .tag = 5, .op = .read, .node = event, .handle = h, .size = 256 }).bytes);
_ = call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
// With no reader, the root's and the pane's act at once.
try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .exec), "Msg direct\n").reply.status);
@@ -1207,6 +1491,40 @@ test "look and exec read back what the last command touched, a ctl write's too"
// A ctl write that makes nothing names the pane it ran at.
try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Verbose off\n").reply.status);
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), rd(p, root_exec, 0, 64).bytes);
+ // Closed, it is no pane to go to: a fresh read of look is empty.
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(made, .ctl), "Del\n").reply.status);
+ try testing.expect(p.paneBySerial(made) == null);
+ try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+}
+
+test "a builtin's Verbose announcement is logged only when it succeeds, its failure said now or by the host later" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var want: [64]u8 = undefined;
+ const said_exit = try std.fmt.bufPrint(&want, "msg {d} Exit\n", .{serial});
+ // Refused as it runs, through a click: the write fails, and no Exit.
+ const f = &p.panes[0].?.file.?;
+ f.saved_revision = f.revision -% 1;
+ f.saved_hash = 0;
+ try testing.expect(wr(p, Node.of(serial, .exec), "Exit\n").errno() != 0);
+ try testing.expect(!th.logHas(p, said_exit));
+ // Refused at the keyboard (no write to fail), edited since: the same.
+ f.revision +%= 1;
+ _ = p.executeBuiltinLine(p.active, "Exit");
+ try testing.expect(!th.logHas(p, said_exit));
+ try testing.expect(!p.quit);
+ // A Save the host could not do, told a step later: the Save goes too.
+ var save_buf: [64]u8 = undefined;
+ const said_save = try std.fmt.bufPrint(&save_buf, "msg {d} Save\n", .{serial});
+ _ = wr(p, Node.of(serial, .exec), "Save\n");
+ p.saveFailed(@intCast(p.paneBySerial(serial).?), "/test.txt", error.AccessDenied);
+ try testing.expect(!th.logHas(p, said_save));
+ // One that does what it says keeps its announcement.
+ _ = wr(p, Node.of(serial, .exec), "Undo\n");
+ var undo_buf: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&undo_buf, "msg {d} Undo", .{serial})));
}
test "an error's words give the errno a mount reads: EINVAL for what is malformed, EIO for what failed" {
@@ -1297,7 +1615,7 @@ test "a Restore of a file that is no dump fails the write before any warning, an
p.fs.late_failure_len = 0;
p.dumpFailed("/nowhere/pardes.dump.zon", error.FileNotFound);
- try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: FileNotFound", p.fs.late_failure[0..p.fs.late_failure_len]);
+ try testing.expectEqualStrings("Dump /nowhere/pardes.dump.zon: file not found", p.fs.late_failure[0..p.fs.late_failure_len]);
}
test "a setting this frontend cannot show says GUI-only, and DumpDir reads back the directory in effect" {
@@ -1316,3 +1634,468 @@ test "a setting this frontend cannot show says GUI-only, and DumpDir reads back
try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 1 << 16).bytes, line) != null);
}
}
+
+test "Shell refuses a path that is no executable, and bare it goes back to the default" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const refused = wr(p, root_ctl, "Shell /nonexistent/zzsh\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: no shell \"/nonexistent/zzsh\"") != null);
+ try testing.expect(th.logHas(p, "no shell \"/nonexistent/zzsh\""));
+ try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno());
+ try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory"));
+ try testing.expectEqualStrings("", p.settings.shell.requested.get());
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell /bin/sh\n").reply.status);
+ try testing.expectEqualStrings("/bin/sh", p.settings.shell.requested.get());
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Shell\n").reply.status);
+ try testing.expectEqualStrings("", p.settings.shell.requested.get());
+ try testing.expect(th.logHas(p, "Shell: the default again, "));
+ var want: [300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rd(p, root_ctl, 0, 8192).bytes, try std.fmt.bufPrint(&want, "Shell {s}\n", .{config.defaultShell()})) != null);
+}
+
+test "a builtin that fails a ctl write logs only its err, so the same failure again is counted" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ const ctl_node = @intFromEnum(tree.TopFile.ctl);
+ for (0..3) |_| try testing.expectEqual(Status.err, wr(p, ctl_node, "Kill zzz\n").reply.status);
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ try testing.expect(std.mem.endsWith(u8, text, "err - ctl: Kill: no running command has that first word \"Kill zzz\" (x3)\n"));
+ try testing.expect(std.mem.indexOf(u8, text, "msg - Kill") == null);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "an exec line over the command limit fails the write and says the limit; a builtin's may be longer" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "echo " ++ "y" ** pardes.exec.command_max ++ "\n";
+ const refused = wr(p, root_exec, long);
+ try testing.expectEqual(E.INVAL, refused.errno());
+ try testing.expectEqualStrings(e_too_long, refused.reply.ename);
+ try testing.expect(th.logHas(p, "a command line is at most 1024 bytes"));
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Msg " ++ "z" ** 1100 ++ "\n").reply.status);
+}
+
+test "Edit's p and = never fail for want of room for +Errors: the text is logged instead" {
+ const p = try withFile(testing.allocator, "one\ntwo\n");
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 80, .rows = 6 } });
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ try testing.expectEqual(Status.ok, wr(p, ctl_node, "Edit ,p\n").reply.status);
+ try testing.expect(th.logHas(p, " one\n") and th.logHas(p, " two\n"));
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(@as(usize, 1), count); // the +Errors that had no room is gone
+}
+
+test "size sets the screen of a session no frontend is attached to, and only of one" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120 40\n").errno()); // a frontend's
+ p.unattended = true;
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "size 120 40\n").reply.status);
+ try testing.expectEqual(@as(u16, 120), p.screen_w);
+ try testing.expectEqual(@as(u16, 40), p.screen_h);
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size 120\n").errno());
+ try testing.expectEqual(E.INVAL, wr(p, root_ctl, "size\n").errno());
+ try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 5 2\n").reply.ename, "at least 20x6, at most 4096x4096") != null);
+ // Too small for the panes it has, each its tag and two rows: refused,
+ // the size as it was.
+ for (0..4) |_| p.newScratchBelow(p.active);
+ p.sync();
+ try testing.expect(std.mem.indexOf(u8, wr(p, root_ctl, "size 80 8\n").reply.ename, "too small for the panes") != null);
+ try testing.expectEqual(@as(u16, 40), p.screen_h);
+}
+
+test "Mount dials its peer and fails, mounting nothing, when none answers; a look through a peer gone says dial failed" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const dead = "/tmp/pardes-test-no-such-peer.sock";
+ const refused = wr(p, root_ctl, "Mount dead " ++ dead ++ "\n");
+ try testing.expectEqual(E.IO, refused.errno());
+ try testing.expectEqualStrings("Mount dead " ++ dead ++ ": dial failed: no answer \"Mount dead " ++ dead ++ "\"", refused.reply.ename);
+ try testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
+ // Mounted when it answered, gone since: a look through it says so.
+ try p.fs.mounts.append(p.gpa, .{ .name = try p.gpa.dupe(u8, "gone"), .dial = try p.gpa.dupe(u8, dead) });
+ const looked = wr(p, @intFromEnum(tree.TopFile.look), "/n/gone/f.txt\n");
+ try testing.expect(looked.errno() != 0);
+ try testing.expect(th.logHas(p, "look: /n/gone/f.txt: dial failed: no answer"));
+}
+
+test "a root exec that fails logs its err against the pane it ran at" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const f = &p.panes[0].?.file.?;
+ f.saved_revision = f.revision -% 1;
+ f.saved_hash = 0;
+ try testing.expect(wr(p, root_exec, "Exit\n").errno() != 0);
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: ", .{serialOf(p)})));
+}
+
+test "size is monotonic: growing is never refused, and a size once taken is taken again" {
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 60 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ p.unattended = true;
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const new = @intFromEnum(tree.TopFile.new);
+ // The dogfood's repro: Newcol, three pane/new, a Tty, then pane/new
+ // until a column has no room (ENOSPC).
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ for (0..3) |_| try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(p.panes[p.active].?.serial, .ctl), "Tty\n").reply.status);
+ p.sync();
+ while (call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status == .ok) p.sync();
+ p.sync();
+ var line: [32]u8 = undefined;
+ // Down a row at a time (59, 58, 57, 56 and on) until one is refused:
+ // once refused, every smaller one is too.
+ var taken: [60]bool = @splat(false);
+ var rows: u16 = 59;
+ var refused = false;
+ while (rows >= 20) : (rows -= 1) {
+ taken[rows] = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ if (refused) try testing.expect(!taken[rows]);
+ if (!taken[rows]) refused = true;
+ }
+ try testing.expect(refused); // the columns are full: some size is too small
+ // Back up: each size is taken again exactly when it was on the way down.
+ rows = 20;
+ while (rows <= 59) : (rows += 1) {
+ const ok = wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{rows})).reply.status == .ok;
+ try testing.expectEqual(taken[rows], ok);
+ }
+ for ([_]u16{ 60, 61, 64, 70, 100 }) |grown| {
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, try std.fmt.bufPrint(&line, "size 160 {d}\n", .{grown})).reply.status);
+ try testing.expectEqual(grown, p.screen_h);
+ }
+ // Out of range is EINVAL, in words 9ns reads as that.
+ const bad = wr(p, root_ctl, "size 5 2\n");
+ try testing.expectEqual(E.INVAL, bad.errno());
+ try testing.expectStringStartsWith(bad.reply.ename, "invalid size");
+}
+
+test "Repl with no such language names a few whole, and where the rest are" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const refused = wr(p, Node.of(serialOf(p), .ctl), "Repl zzlang\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Repl: no language \"zzlang\"; - or one like zig ada bash c c_sharp clojure (") != null);
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, " more: docs/tags.md, Repl)") != null);
+}
+
+test "a 10k-line Edit text block is taken in linear time and memory" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ var block: std.ArrayList(u8) = .empty;
+ defer block.deinit(gpa);
+ try block.appendSlice(gpa, "Edit $a\n");
+ for (0..10_000) |i| try block.print(gpa, "line {d} of the block {{ with a brace }}\n", .{i});
+ try block.appendSlice(gpa, ".\n");
+ const started = std.Io.Clock.awake.now(std.testing.io);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serialOf(p), .ctl), block.items).reply.status);
+ const took = started.durationTo(std.Io.Clock.awake.now(std.testing.io)).toNanoseconds();
+ try testing.expect(took < std.time.ns_per_s);
+ try testing.expect(p.scratch.queryCapacity() < 50 * 1024 * 1024);
+ try testing.expectEqual(@as(usize, 10_001), std.mem.count(u8, p.panes[0].?.file.?.content, "\n"));
+}
+
+test "a file changed on disk under unsaved edits is not reloaded: it says so, get asks, Save warns once" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ // Clean, it takes the new text.
+ pardes.panes.File.changed(p, 0, "two\n");
+ try testing.expectEqualStrings("two\n", f.content);
+ var reloaded: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial})));
+ // Edited, it keeps its text and stays dirty.
+ _ = wr(p, Node.of(serial, .body), "mine\n");
+ pardes.panes.File.changed(p, 0, "three\n");
+ try testing.expectEqualStrings("two\nmine\n", f.content);
+ try testing.expect(f.revision != f.saved_revision);
+ try testing.expect(th.logHas(p, "changed on disk (get reloads it, Save overwrites it)"));
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "changed {d}\n", .{serial})));
+ // Exit and get still ask about the edits.
+ try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, wr(p, Node.of(serial, .ctl), "get\n").reply.ename, "Modified") != null);
+ // Save warns once before overwriting the newer file, then writes.
+ const first = wr(p, Node.of(serial, .ctl), "Save\n");
+ try testing.expectEqual(Status.err, first.reply.status);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "modified on disk since read") != null);
+ try testing.expect(wr(p, Node.of(serial, .ctl), "Save\n").saved);
+}
+
+test "a looked word is found in its own pane, next place after the dot, wrapping, and opens no pane" {
+ const p = try withFile(testing.allocator, "foo x foo y\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const look_node = Node.of(serial, .look);
+ const dot = Node.of(serial, .dot);
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ for ([_][]const u8{ " 0 3 ", " 6 9 ", " 0 3 " }) |want| {
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ try testing.expectEqualStrings(want, rd(p, dot, 0, 64).bytes);
+ }
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expectEqual(count, after);
+ // LookWord list: the +Search listing, as before.
+ try testing.expect(p.executeBuiltinLine(0, "LookWord list"));
+ try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
+ after = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try testing.expect(after > count);
+ // ...and look reads back that +Search pane, where the places are.
+ var listed = false;
+ var rows = std.mem.tokenizeScalar(u8, rd(p, look_node, 0, 256).bytes, '\n');
+ while (rows.next()) |row| {
+ const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, row, 10)).?].?;
+ if (made.file) |f| if (f.output) |o| if (o.from == .search) {
+ listed = true;
+ };
+ }
+ try testing.expect(listed);
+}
+
+test "every word's /commands description is its own doc comment's first sentence, no citation" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const listed = rd(p, @intFromEnum(tree.TopFile.commands), 0, 1 << 16).bytes;
+ for ([_][]const u8{
+ "\nRepl arg pane -- Binds this terminal as a language's REPL, so an exec on that language's files is typed into it instead of run.\n",
+ "\nJoincol root -- Fold the active pane's column into the one on its right, keeping its panes.\n",
+ "\nExit root -- Quit the editor, refusing once while a pane holds unsaved text: an Exit with nothing edited since discards it.\n",
+ "\nColors arg root on,off -- Syntax colours in text panes, on or off.\n",
+ // fs.md's examples, as they read.
+ "\nNewcol root -- An empty column right of the keyboard's, its tag taking the keyboard.\n",
+ "\nSave arg pane -- Write the pane's text to its file, or to the file its argument names.\n",
+ "\nVerbose arg root on,off -- A builtin says its own name on the message row as it runs, on or off.\n",
+ "\nPlacement arg root acme,pardes -- Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules.\n",
+ "\nKill arg root -- acme's Kill: stop the commands pardes started",
+ }) |row| try testing.expect(std.mem.indexOf(u8, listed, row) != null);
+ try testing.expect(std.mem.indexOf(u8, listed, ".c:") == null);
+ // Every description is the first sentence of the doc comment just
+ // above its word's declaration in builtins.zig.
+ const source = @embedFile("../builtins.zig");
+ var it = std.mem.tokenizeScalar(u8, listed, '\n');
+ var undescribed: usize = 0;
+ while (it.next()) |line| {
+ // Every builtin has one: a word nobody can look up is no use.
+ const dashes = std.mem.indexOf(u8, line, " -- ") orelse {
+ std.debug.print("no doc comment: {s}\n", .{line});
+ undescribed += 1;
+ continue;
+ };
+ const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ').?];
+ if (config.Runtime.find(word)) |setting| {
+ try testing.expect(std.mem.endsWith(u8, line, setting.doc));
+ continue;
+ }
+ var needle: [64]u8 = undefined;
+ const at = std.mem.indexOf(u8, source, try std.fmt.bufPrint(&needle, "\npub const {s} = struct", .{word})).?;
+ const said = std.mem.trimEnd(u8, line[dashes + 4 ..], "…");
+ // Its opening words are in the comment block right above.
+ const head = said[0..@min(said.len, 10)];
+ const block_start = std.mem.lastIndexOf(u8, source[0..at], "\n\n") orelse 0;
+ try testing.expect(std.mem.indexOf(u8, source[block_start..at], head) != null);
+ }
+ try testing.expectEqual(0, undescribed);
+}
+
+test "Newcol at the column cap fails the write and says the cap" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 4000, .rows = 30 } });
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ while (p.ncol < pardes.MAX_COLS) {
+ // From the widest column's tag, so no column gets too narrow first.
+ p.sync();
+ var widest: usize = 0;
+ for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) {
+ widest = c;
+ };
+ pardes.tagline.enterHeader(p, widest);
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "Newcol\n").reply.status);
+ }
+ const refused = wr(p, root_ctl, "Newcol\n");
+ try testing.expectEqual(Status.err, refused.reply.status);
+ var want: [64]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, try std.fmt.bufPrint(&want, "no space for a column: {d} max", .{pardes.MAX_COLS})) != null);
+ try testing.expect(th.logHas(p, "no space for a column"));
+}
+
+test "Save on a terminal asked over 9P is an ask in the log, answered with a path, and not asked twice" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Save\n").reply.status);
+ var want: [48]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} save path\n", .{serial})));
+ try testing.expect(p.panes[id].?.prompt == .save);
+ // Again: the one open stands, one ask in the log.
+ _ = wr(p, Node.of(serial, .tagexec), "Save\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, "save path\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ // Answered: the path is written to, the prompt goes.
+ const answered = wr(p, Node.of(serial, .ctl), "answer /tmp/pardes-ask-save.txt\n");
+ try testing.expectEqual(Status.ok, answered.reply.status);
+ try testing.expect(p.panes[id].?.prompt == .none);
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} /tmp/pardes-ask-save.txt\n", .{serial})));
+}
+
+test "get names the path it could not read, logs the edits it threw away, and a deleted file is said once" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ const path = f.path;
+ // Its file is not on disk: get names it.
+ const missing = wr(p, Node.of(serial, .ctl), "get\n");
+ try testing.expectEqual(E.NOENT, missing.errno());
+ var want: [512]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "get: {s}: no such file", .{path}), missing.reply.ename);
+ // Deleted on disk: said once.
+ pardes.panes.File.deleted(p, 0);
+ pardes.panes.File.deleted(p, 0);
+ var rec: [32]u8 = undefined;
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, try std.fmt.bufPrint(&rec, "changed {d} deleted\n", .{serial})));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "a builtin that fails through exec, tagexec or a column's exec fails the write with one err and no msg" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ const serial = serialOf(p);
+ for ([_]u64{ root_exec, Node.of(serial, .exec), @intFromEnum(tree.TopFile.tagexec), Node.ofCol(pardes.layout.columnSerial(p, 0), .exec) }) |node| {
+ const failed = wr(p, node, "Kill zzz\n");
+ try testing.expectEqual(Status.err, failed.reply.status);
+ try testing.expect(std.mem.indexOf(u8, failed.reply.ename, "Kill: no running command has that first word") != null);
+ }
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ try testing.expect(std.mem.indexOf(u8, text, "msg ") == null);
+ // The root's exec ran at the pane, so its err and the pane's exec's are
+ // the same record, counted.
+ try testing.expectEqual(@as(usize, 3), std.mem.count(u8, text, "Kill: no running command has that first word"));
+ try testing.expect(std.mem.indexOf(u8, text, "first word (x2)") != null);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ // A control character is refused with its reason.
+ try testing.expectEqualStrings(e_control, wr(p, root_exec, "Msg a\x01b\n").reply.ename);
+ // A look miss: one err, no msg.
+ _ = wr(p, @intFromEnum(tree.TopFile.look), "zzqqnotthere\n");
+ try testing.expect(th.logHas(p, "look: no match for \"zzqqnotthere\""));
+ try testing.expect(!th.logHas(p, "msg 1 Look: no match"));
+}
+
+test "a word written to a raw terminal's look is listed as in normal mode" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one MARK two\r\n" } });
+ p.panes[id].?.body.mode = .tty;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), "MARK\n").reply.status);
+ const said = std.mem.trimEnd(u8, rd(p, Node.of(serial, .look), 0, 64).bytes, "\n");
+ const made = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, said, 10)).?].?;
+ try testing.expect(made.file.?.output.?.from == .search);
+}
+
+test "@p<serial>:<address> takes any sam address; a miss and an unknown serial are errs" {
+ const p = try withFile(testing.allocator, "foo x foo y\nbar\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const root = @intFromEnum(tree.TopFile.look);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { addr: []const u8, want: []const u8 };
+ for ([_]Case{
+ .{ .addr = "0/foo/", .want = " 0 3 " },
+ .{ .addr = "/foo/", .want = " 6 9 " },
+ .{ .addr = "#12,#15", .want = " 12 15 " },
+ .{ .addr = "$", .want = " 16 16 " },
+ .{ .addr = "1,2", .want = " 0 16 " },
+ // Backward, as in addr: from the dot's start, wrapping to the last.
+ .{ .addr = "?foo?", .want = " 6 9 " },
+ .{ .addr = "$-?y?", .want = " 10 11 " },
+ }) |c| {
+ var line: [64]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:{s}\n", .{ serial, c.addr })).reply.status);
+ try testing.expectEqualStrings(c.want, rd(p, dot, 0, 64).bytes);
+ }
+ var line: [64]u8 = undefined;
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
+ try testing.expect(th.logHas(p, "look: no match for regexp"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+ _ = wr(p, root, "@p999:/foo/\n");
+ try testing.expect(th.logHas(p, "look: no pane @p999"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+ // A line address to no pane is a miss too, not the active pane.
+ _ = wr(p, root, "@p77:1\n");
+ try testing.expect(th.logHas(p, "look: no pane @p77"));
+ try testing.expectEqualStrings("", rd(p, root, 0, 64).bytes);
+}
+
+test "a look that goes to another pane leaves the looking pane's dot alone" {
+ const p = try withFile(testing.allocator, "alpha bravo\ncharlie\n");
+ defer p.deinit();
+ const source = serialOf(p);
+ const other = try th.newPane(p);
+ _ = wr(p, Node.of(other, .body), "one\ntwo\nthree\n");
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "f one\nf two\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ for ([_][]const u8{ "@p{d}:2", "@p{d}:/three/", "{s}/f.txt:2" }) |form| {
+ @import("pane.zig").setDot(p.panes[p.paneBySerial(source).?].?, .{ .q0 = 6, .q1 = 11 });
+ const text = if (form[0] == '@')
+ (if (std.mem.eql(u8, form, "@p{d}:2")) try std.fmt.bufPrint(&line, "@p{d}:2\n", .{other}) else try std.fmt.bufPrint(&line, "@p{d}:/three/\n", .{other}))
+ else
+ try std.fmt.bufPrint(&line, "{s}/f.txt:2\n", .{dir});
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source, .look), text).reply.status);
+ try testing.expect(p.active != p.paneBySerial(source).?);
+ try testing.expectEqualStrings(" 6 11 ", rd(p, Node.of(source, .dot), 0, 64).bytes);
+ }
+}
+
+test "@p<terminal>:<address> takes any address over the terminal's lines, selecting the match" {
+ const p = try th.withTerm(testing.allocator);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const id = p.paneBySerial(serial).?;
+ p.update(.{ .output = .{ .pane = @intCast(id), .bytes = "one\r\ntwo MARK three\r\n" } });
+ const pane = p.panes[id].?;
+ const root = @intFromEnum(tree.TopFile.look);
+ var line: [64]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:0/MARK/\n", .{serial})).reply.status);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_row);
+ try testing.expect(pane.body.vsel.active);
+ // #n lands on that byte: #5 is the `w` of `two`.
+ try testing.expectEqual(Status.ok, wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:#5\n", .{serial})).reply.status);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_row);
+ try testing.expectEqual(@as(i32, 1), pane.body.cur_col);
+ _ = wr(p, root, try std.fmt.bufPrint(&line, "@p{d}:/zzqq/\n", .{serial}));
+ try testing.expect(th.logHas(p, "look: no match for regexp"));
+}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index ccc61352..98799774 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -110,7 +110,9 @@ pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply {
// ---- the editor-wide log ----
-pub const LogKind = enum { new, del, rename, save };
+/// `unsaved`: a pane a refusal (Exit, Restore, Del, Delcol) is about,
+/// logged before the write's err so a script reads which, whole.
+pub const LogKind = enum { new, del, rename, save, unsaved };
/// A pane leaving, before its state goes with it; one never announced
/// leaves silently.
@@ -122,6 +124,12 @@ pub fn noteRetire(p: *Pardes, pane: *Pane) void {
pane.fs.unannounced = false;
return;
}
+ // A question still standing ends with it, so a follower of its `ask`
+ // hears the end.
+ if (pane.prompt == .repl_choice or pane.prompt == .del_side or pane.prompt == .save) {
+ var rec: [32]u8 = undefined;
+ notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} -", .{pane.serial}) catch "");
+ }
noteLog(p, .del, pane);
}
@@ -143,12 +151,20 @@ pub fn noteLog(p: *Pardes, kind: LogKind, pane: *Pane) void {
}
/// Records `msg <serial> <text>` for what the editor said, `-` for no pane.
-pub fn noteMessage(p: *Pardes, serial: u32, text: []const u8) void {
+/// Text past LoggedMessage.cap bytes is cut between words with an ellipsis,
+/// as an err record's reason is.
+pub fn noteMessage(p: *Pardes, serial: u32, said: []const u8) void {
var buf: [pardes.Messages.LoggedMessage.cap + 32]u8 = undefined;
- pushLog(p, (if (serial == 0)
- std.fmt.bufPrint(&buf, "msg - {s}\n", .{text})
+ const kept = pardes.Messages.clip(said, pardes.Messages.LoggedMessage.cap);
+ const cut = if (kept.len < said.len) "…" else "";
+ // A builtin's own word for what it did stands in for its Verbose
+ // announcement just before (`Undo` then `Undo: nothing to undo`), so
+ // that 1000 Undos with nothing left are one line, counted, as err's are.
+ dropAnnouncement(p, serial, kept);
+ pushCounting(p, (if (serial == 0)
+ std.fmt.bufPrint(&buf, "msg - {s}{s}\n", .{ kept, cut })
else
- std.fmt.bufPrint(&buf, "msg {d} {s}\n", .{ serial, text })) catch return);
+ std.fmt.bufPrint(&buf, "msg {d} {s}{s}\n", .{ serial, kept, cut })) catch return);
}
/// Records `err <serial|-> <file>: <why>` for a write or truncation that was
@@ -165,16 +181,25 @@ pub fn notePath(p: *Pardes, what: []const u8, path: []const u8) void {
pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
const why = if (reply.ename.len > 0) reply.ename else cloud9.fs.errString(reply.errno);
- var name: [16]u8 = undefined;
+ var name: [32]u8 = undefined;
var serial: []const u8 = "-";
var file: []const u8 = "os";
if (req.node & tree.sources.archive_node != 0) file = "src";
if (req.node != pardes.filesystem.os_root and req.node & pardes.filesystem.os_node == 0 and req.node & tree.sources.archive_node == 0) {
if (tree.Node.target(req.node)) |t| switch (t) {
- .top => |f| file = f.fileName(),
+ .top => |f| {
+ file = f.fileName();
+ // The root's exec or look ran at the keyboard's pane: its
+ // failure is that pane's, as a click there would be.
+ if ((f == .exec or f == .look) and p.fs.ran_at != 0)
+ serial = std.fmt.bufPrint(&name, "{d}", .{p.fs.ran_at}) catch "-";
+ },
.col => |c| {
serial = "-";
- file = std.fmt.bufPrint(&name, "col/{d}/tag", .{c.serial}) catch "col";
+ file = (if (c.file == .dir)
+ std.fmt.bufPrint(&name, "col/{d}", .{c.serial})
+ else
+ std.fmt.bufPrint(&name, "col/{d}/{s}", .{ c.serial, @tagName(c.file) })) catch "col";
},
.pane => |pt| {
serial = std.fmt.bufPrint(&name, "{d}", .{pt.serial}) catch "-";
@@ -189,26 +214,91 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
};
}
var buf: [256]u8 = undefined;
- const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}\n", .{ serial, file, why[0..@min(why.len, 200)] }) catch return;
+ // A long reason is cut between words, with an ellipsis, never mid-word.
+ const kept = pardes.Messages.clip(why, 200);
+ const record = std.fmt.bufPrint(&buf, "err {s} {s}: {s}{s}\n", .{ serial, file, kept, if (kept.len < why.len) "…" else "" }) catch return;
for (record[0 .. record.len - 1]) |*c| if (c.* < ' ') {
c.* = ' ';
};
+ pushCounting(p, record);
+}
+
+/// Drops the newest record when it is the Verbose announcement (`msg
+/// <serial> <Word>`) of the builtin whose words `text` are (`Word: ...`):
+/// what the builtin said, or the err its failure logs, takes its place.
+/// Not one a follower has read.
+pub fn dropAnnouncement(p: *Pardes, serial: u32, text: []const u8) void {
+ const last = newest(p) orelse return;
+ if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
+ const rest = last.text["msg ".len..];
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+ const word = rest[@min(sp + 1, rest.len)..];
+ var who: [16]u8 = undefined;
+ const mine = if (serial == 0) "-" else std.fmt.bufPrint(&who, "{d}", .{serial}) catch "";
+ if (std.mem.eql(u8, rest[0..sp], mine) and word.len > 0 and std.mem.indexOfScalar(u8, word, ' ') == null and
+ text.len > word.len and std.mem.startsWith(u8, text, word) and text[word.len] == ':')
+ p.fs.log.buf.shrinkRetainingCapacity(last.at);
+}
+
+/// The sequence number of the newest log record when it is a msg that
+/// ends in `word` alone: not one counted (`(x2)`) over an earlier say.
+pub fn newestSaying(p: *Pardes, word: []const u8) ?u64 {
+ const last = newest(p) orelse return null;
+ if (!std.mem.startsWith(u8, last.text, "msg ") or !std.mem.endsWith(u8, last.text, word)) return null;
+ if (last.text[last.text.len - word.len - 1] != ' ') return null;
+ return last.seq;
+}
+
+/// Takes record `seq` back out of the log, unless a follower has read it.
+pub fn dropRecord(p: *Pardes, seq: u64) void {
+ if (followerRead(p, seq)) return;
+ const q = &p.fs.log;
+ var at = q.head;
+ var s = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (s += 1) {
+ const size = 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ if (s == seq) {
+ const items = q.buf.items;
+ std.mem.copyForwards(u8, items[at..], items[at + size ..]);
+ q.buf.shrinkRetainingCapacity(items.len - size);
+ return;
+ }
+ at += size;
+ }
+}
+
+/// A builtin's failure: its Verbose announcement goes from the log, when
+/// `text` is that builtin's (it starts with its word) or `any`, as for a
+/// failure said while the builtin itself runs.
+pub fn dropFailedAnnouncement(p: *Pardes, text: []const u8, any: bool) void {
+ const seq = p.fs.announced_seq orelse return;
+ const word = p.fs.announced_word[0..p.fs.announced_word_len];
+ if (!any and !(std.mem.startsWith(u8, text, word) and text.len > word.len and (text[word.len] == ' ' or text[word.len] == ':'))) return;
+ p.fs.announced_seq = null;
+ dropRecord(p, seq);
+}
+
+/// Drops the newest record when it is a msg saying `text` (of any pane)
+/// that no follower has read: a failure its write's err record will say.
+pub fn dropMessage(p: *Pardes, text: []const u8) void {
+ const last = newest(p) orelse return;
+ if (followerRead(p, last.seq) or !std.mem.startsWith(u8, last.text, "msg ")) return;
+ const rest = last.text["msg ".len..];
+ const sp = std.mem.indexOfScalar(u8, rest, ' ') orelse return;
+ if (std.mem.eql(u8, rest[sp + 1 ..], text)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
+}
+
+/// Pushes `record`, or, when it repeats the newest record word for word,
+/// counts that one instead: `<record> (x2)`, the count being every time it
+/// was said. One a follower has already read is not rewritten: the repeat
+/// is a new line carrying the running count, `(x3)`.
+fn pushCounting(p: *Pardes, record: []u8) void {
// A client retrying a write that fails the same way would fill the ring
// with one line, so a repeat of the newest record is that record counted,
// `(x3)`, as +Messages counts its repeats; unless a follower has read it
// already and so waits on the repeat as a line of its own.
- const q = &p.fs.log;
- var at = q.head;
- var last: ?usize = null;
- var seq = q.dropped;
- while (at + 4 <= q.buf.items.len) : (seq += 1) {
- last = at;
- at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
- }
- const last_at = last orelse return pushLog(p, record);
- const last_seq = seq - 1;
- var said = q.buf.items[last_at + 4 ..];
- said = said[0 .. said.len - 1];
+ const last = newest(p) orelse return pushLog(p, record);
+ var said = last.text;
var times: u32 = 1;
if (std.mem.endsWith(u8, said, ")")) if (std.mem.lastIndexOf(u8, said, " (x")) |x| {
if (std.fmt.parseInt(u32, said[x + 3 .. said.len - 1], 10)) |n| {
@@ -217,14 +307,35 @@ pub fn noteError(p: *Pardes, req: Req, reply: Reply) void {
} else |_| {}
};
if (!std.mem.eql(u8, said, record[0 .. record.len - 1])) return pushLog(p, record);
+ var counted: [pardes.Messages.LoggedMessage.cap + 64]u8 = undefined;
+ const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
+ if (!followerRead(p, last.seq)) p.fs.log.buf.shrinkRetainingCapacity(last.at);
+ pushLog(p, again);
+}
+
+/// The newest record in the ring: where it starts, its sequence number and
+/// its text without the newline.
+fn newest(p: *Pardes) ?struct { at: usize, seq: u64, text: []const u8 } {
+ const q = &p.fs.log;
+ var at = q.head;
+ var last: ?usize = null;
+ var seq = q.dropped;
+ while (at + 4 <= q.buf.items.len) : (seq += 1) {
+ last = at;
+ at += 4 + std.mem.readInt(u32, q.buf.items[at..][0..4], .little);
+ }
+ const last_at = last orelse return null;
+ const text = q.buf.items[last_at + 4 ..];
+ return .{ .at = last_at, .seq = seq - 1, .text = text[0 .. text.len - 1] };
+}
+
+/// Whether a follower has read the record `seq`, or is part way through it.
+fn followerRead(p: *Pardes, seq: u64) bool {
for (p.fs.opens) |o| if (o.node != 0 and o.what == .log) {
const l = o.what.log;
- if (l.follow and (l.next > last_seq or (l.next == last_seq and l.part > 0))) return pushLog(p, record);
+ if (l.follow and (l.next > seq or (l.next == seq and l.part > 0))) return true;
};
- var counted: [280]u8 = undefined;
- const again = std.fmt.bufPrint(&counted, "{s} (x{d})\n", .{ said, times +| 1 }) catch return;
- q.buf.shrinkRetainingCapacity(last_at);
- pushLog(p, again);
+ return false;
}
/// The log is one ring that records whether or not anyone reads it. A record
@@ -322,7 +433,12 @@ pub fn readLog(p: *Pardes, req: Req) Reply {
/// records instead of ending; closing it is the way back.
pub fn writeLog(p: *Pardes, req: Req) Reply {
const slot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.log;
- if (!std.mem.eql(u8, std.mem.trim(u8, req.data, " \t\r\n"), "follow")) return Reply.fail(req.tag, E.INVAL);
+ const word = std.mem.trim(u8, req.data, " \t\r\n");
+ // `follow` reads what the open froze, then waits; `follow new` skips
+ // what was there and waits for what comes after, as tail -n0 -f does.
+ if (std.mem.eql(u8, word, "follow new")) {
+ slot.pos = slot.bytes.len;
+ } else if (!std.mem.eql(u8, word, "follow")) return Reply.fail(req.tag, E.INVAL);
slot.follow = true;
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}
@@ -459,6 +575,16 @@ pub fn noteAction(
return true;
}
+/// A record that follows another with no place of its own, `<c>0 0 0 <n>
+/// <text>`: a chorded exec's argument and its location (acme exec.c:182).
+pub fn noteFollow(p: *Pardes, id: usize, action: Action, text: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (pane.fs.readers == 0) return;
+ var buf: [max_record_text + 64]u8 = undefined;
+ pane.fs.events.push(p.gpa, formatRecord(&buf, p.fs.origin, action, 0, 0, 0, text));
+ p.fs.news = true;
+}
+
pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
if (id >= MAX_PANES or bytes.len == 0) return;
const pf = &(p.panes[id] orelse return).fs;
@@ -472,7 +598,7 @@ pub fn notePtyOutput(p: *Pardes, id: usize, bytes: []const u8) void {
p.fs.news = true;
}
-const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, text: ?[]const u8 = null };
+const EventRecord = struct { origin: u8, action: Action, q0: u32, q1: u32, flag: u32 = 0, text: ?[]const u8 = null };
const EventReader = struct {
data: []const u8,
@@ -493,8 +619,9 @@ const EventReader = struct {
// is taken too, so a click with no place in the text (one written
// to a pane's own look or exec) can be written back and done.
var text: ?[]const u8 = null;
+ var flag: u32 = 0;
if (i < er.data.len and er.data[i] != '\n') {
- _ = scanNumber(er.data, &i) orelse return null;
+ flag = scanNumber(er.data, &i) orelse return null;
const n = scanNumber(er.data, &i) orelse return null;
if (i >= er.data.len or er.data[i] != ' ' or er.data.len - (i + 1) < n) return null;
text = er.data[i + 1 ..][0..n];
@@ -502,7 +629,7 @@ const EventReader = struct {
}
if (i >= er.data.len or er.data[i] != '\n') return null;
er.i = i + 1;
- return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .text = text };
+ return .{ .origin = origin, .action = action, .q0 = q0, .q1 = q1, .flag = flag, .text = text };
}
};
@@ -530,7 +657,9 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
else => return tree.failText(req.tag, E.INVAL, tree.e_bad_event),
}
const n = if (r.action.onTag()) tag.len else body.len;
- if (r.q0 > r.q1 or r.q1 > n) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
+ if (r.q0 > r.q1) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
+ // Well formed, but not a range of this text: say which.
+ if (r.q1 > n) return tree.failText(req.tag, E.INVAL, if (r.action.onTag()) "range past end of tag" else "range past end of body");
}
if (check.i != req.data.len) return tree.failText(req.tag, E.INVAL, tree.e_bad_event);
}
@@ -548,9 +677,65 @@ pub fn writeEvent(p: *Pardes, req: Req, id: usize) Reply {
// been (acme's xfideventwrite, xfid.c:842-844): a REPL bound for
// it takes it. One that came from a write to exec (`F`) was
// never a click, and runs as the command it was.
- .body_exec => _ = exec.executeFrom(p, live, text, r.origin != 'F'),
- .tag_exec => _ = exec.execute(p, live, text),
- .body_look, .tag_look => look.lookAt(p, live, text),
+ // The chorded exec a reader was told of runs with its argument
+ // (mouse.reportGesture), when it is that range written back.
+ .body_exec, .tag_exec => {
+ const pf = &pane.fs;
+ // The two records that follow a chorded exec (its argument
+ // and where it came from), written back after it, are its:
+ // consumed, never run as commands of their own.
+ if (pf.chord_follow > 0 and r.q0 == 0 and r.q1 == 0 and r.flag == 0) {
+ pf.chord_follow -= 1;
+ // The argument a command written back alone waited for.
+ if (pf.chord_pending) |command| {
+ pf.chord_pending = null;
+ defer p.gpa.free(command);
+ const line = exec.withArg(p, command, r.text orelse "");
+ if (pf.chord_pending_tag) _ = exec.execute(p, live, line) else _ = exec.executeFrom(p, live, line, true);
+ }
+ continue;
+ }
+ var arg = if (pf.chord_arg) |a| (if (pf.chord_at.q0 == r.q0 and pf.chord_at.q1 == r.q1) a else null) else null;
+ if (r.flag & flag_chorded != 0) {
+ // Its argument is the next record when it came in the
+ // same write; the rest that follow are consumed.
+ pf.chord_follow = 2;
+ const save = run.i;
+ if (run.next()) |following| if (following.q0 == 0 and following.q1 == 0 and following.flag == 0) {
+ if (following.text) |t| arg = p.scratch.allocator().dupe(u8, t) catch arg;
+ pf.chord_follow = 1;
+ } else {
+ run.i = save;
+ };
+ // Alone, with no argument kept: it waits for its own.
+ if (arg == null) {
+ if (pf.chord_pending) |old| p.gpa.free(old);
+ pf.chord_pending = p.gpa.dupe(u8, text) catch null;
+ pf.chord_pending_tag = r.action == .tag_exec;
+ continue;
+ }
+ }
+ if (r.action == .tag_exec)
+ _ = exec.execute(p, live, exec.withArg(p, text, arg))
+ else
+ _ = exec.executeFrom(p, live, exec.withArg(p, text, arg), r.origin != 'F');
+ if (arg != null) if (p.paneBySerial(serial)) |still| {
+ const spf = &p.panes[still].?.fs;
+ if (spf.chord_arg) |a| p.gpa.free(a);
+ spf.chord_arg = null;
+ };
+ },
+ // What it found is what the look file reads back next, as a
+ // look written there would leave (ctl.run).
+ .body_look, .tag_look => {
+ p.look_missed = false;
+ look.lookAt(p, live, text);
+ p.fs.results_len = 0;
+ if (!p.look_missed) if (p.panes[p.active]) |found| {
+ p.fs.results[0] = found.serial;
+ p.fs.results_len = 1;
+ };
+ },
else => unreachable,
}
}
@@ -726,7 +911,7 @@ test "a pane deleted while its event file is open leaves no suppression behind"
try testing.expectEqual(@as(u16, 0), p.fs.listeners);
// The reader's next read hears what acme says of a window gone under it.
const shut = call(p, .{ .tag = 19, .op = .read, .node = event, .handle = a.reply.handle, .size = 64 });
- try testing.expectEqual(E.IO, shut.errno());
+ try testing.expectEqual(E.NOENT, shut.errno());
try testing.expectEqualStrings(tree.e_shut_down, shut.reply.ename);
_ = call(p, .{ .tag = 20, .op = .release, .node = event, .handle = a.reply.handle });
@@ -767,6 +952,12 @@ test "writing an event record back performs the action it names" {
try testing.expectEqual(E.INVAL, wr(p, event, bad).errno());
try testing.expectEqual(@as(usize, 0), pane.msg_len);
}
+ // A look written back sets what look reads back.
+ _ = wr(p, event, "FL0 3\n");
+ var want_serial: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want_serial, "{d}\n", .{serial}), rd(p, @intFromEnum(tree.TopFile.look), 0, 64).bytes);
+ // Well formed but past the text: said so, not "bad event syntax".
+ try testing.expectEqualStrings("range past end of body", wr(p, event, "MX0 999\n").reply.ename);
_ = call(p, .{ .tag = 23, .op = .open, .node = event });
p.fs.origin = 'K';
@@ -848,10 +1039,10 @@ test "the log records whether or not anyone reads, and an open that follows wait
try testing.expectEqual(Status.again, rdf.next(p, log, fh, frozen).reply.status);
// A shell's `read` asks for a few bytes at a time: the record comes in
- // pieces rather than failing. Every repeat is its own line.
+ // pieces rather than failing. A repeat the follower waits on, having
+ // read the first, is its own line.
const said_by = p.panes[0].?.serial;
p.setMessage(0, "same failure");
- p.setMessage(0, "same failure");
var pieced: [64]u8 = undefined;
var got: usize = 0;
while (got == 0 or pieced[got - 1] != '\n') {
@@ -860,7 +1051,8 @@ test "the log records whether or not anyone reads, and an open that follows wait
got += piece.len;
}
try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), pieced[0..got]);
- try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes);
+ p.setMessage(0, "same failure");
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "msg {d} same failure (x2)\n", .{said_by}), rdf.next(p, log, fh, frozen).bytes);
// A follower the ring outran hears how much it missed, then carries on.
var filler: [200]u8 = @splat('x');
@@ -911,19 +1103,20 @@ test "a refused or failed write is an err record in the log, saying which file a
// A write that succeeds says nothing.
_ = wr(p, Node.of(serial, .addr), "#0");
try testing.expectEqual(Status.again, call(p, .{ .tag = 5, .op = .read, .node = log, .handle = f, .off = frozen, .size = 4096 }).reply.status);
- // A follower that has read an err waits on its repeat: a line of its own.
+ // A follower that has read an err waits on its repeat: a line of its
+ // own, with the running count.
_ = wr(p, Node.of(serial, .addr), "/nowhere/");
try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
_ = wr(p, Node.of(serial, .addr), "/nowhere/");
- try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp\n", .{serial}), next.record(p, f, frozen));
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "err {d} addr: no match for regexp (x2)\n", .{serial}), next.record(p, f, frozen));
_ = call(p, .{ .tag = 6, .op = .release, .node = log, .handle = f });
- // With the follower gone, repeats are the newest record counted: the
- // one it read, and three more.
+ // With the follower gone, repeats are the newest record counted on:
+ // its two, and three more.
for (0..3) |_| _ = wr(p, Node.of(serial, .addr), "/elsewhere/");
const g = call(p, .{ .tag = 7, .op = .open, .node = log }).reply.handle;
const text = call(p, .{ .tag = 8, .op = .read, .node = log, .handle = g, .size = 1 << 16 }).bytes;
- try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x4)\n", .{serial})));
+ try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "regexp\nerr {d} addr: no match for regexp (x5)\n", .{serial})));
_ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = g });
}
@@ -1020,3 +1213,46 @@ test "a click in the body reports the offsets of the word it takes, so the recor
_ = call(p, .{ .tag = 3, .op = .release, .node = event, .handle = h });
}
}
+
+test "follow new skips what the log held and waits for what comes after" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ p.update(.tick); // the pane's `new` is logged first
+ while (p.nextEffect()) |_| {}
+ p.setMessage(0, "before the open");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = log, .handle = h, .data = "follow new\n" }).reply.status);
+ try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = log, .handle = h, .size = 4096 }).reply.status);
+ p.setMessage(0, "after it");
+ try testing.expect(std.mem.endsWith(u8, call(p, .{ .tag = 4, .op = .read, .node = log, .handle = h, .size = 4096 }).bytes, " after it\n"));
+ _ = call(p, .{ .tag = 5, .op = .release, .node = log, .handle = h });
+}
+
+test "a long msg record is cut between words at its cap, with an ellipsis" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const long = "word " ** 60; // 300 bytes
+ _ = wr(p, Node.of(serialOf(p), .ctl), "Msg " ++ long ++ "\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ const at = std.mem.lastIndexOf(u8, text, "msg ").?;
+ const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
+ try testing.expect(std.mem.endsWith(u8, record, " word…\n"));
+ try testing.expect(record.len <= "msg 1 ".len + pardes.Messages.LoggedMessage.cap + "…\n".len);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
+
+test "a long err record is cut between words, with an ellipsis" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n");
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?;
+ const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
+ try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index c594c7a3..d8329ccc 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -41,6 +41,9 @@ pub const State = struct {
/// rather than act at the address before it, which acme would do.
addr_failed: bool = false,
limit: ?Range = null,
+ /// The limit a truncation cleared, kept until the write that follows it
+ /// on that open (a shell's `>`): a write that fails puts it back.
+ limit_truncated: ?Range = null,
/// Opens of `event`, which hold the pane scripted.
readers: u16 = 0,
/// One of `readers` reads `event`; a second reading open is refused.
@@ -74,10 +77,23 @@ pub const State = struct {
/// Installed during this update; /log hears about it once the update ends
/// and the pane has its file, name and place.
unannounced: bool = false,
+ /// A chorded exec's argument an event reader was told of (flag 8), kept
+ /// for the record's write-back at `chord_at`, which runs with it.
+ chord_arg: ?[]u8 = null,
+ chord_at: Range = .{},
+ /// A chorded record written back: how many of its two follow-up records
+ /// (argument, location) are still to be consumed rather than run.
+ chord_follow: u8 = 0,
+ /// A chorded record written back alone with no argument kept: the
+ /// command, run when its argument record comes.
+ chord_pending: ?[]u8 = null,
+ chord_pending_tag: bool = false,
pub const Range = struct { q0: u32 = 0, q1: u32 = 0 };
pub fn deinit(pf: *State, gpa: std.mem.Allocator) void {
+ if (pf.chord_arg) |a| gpa.free(a);
+ if (pf.chord_pending) |c| gpa.free(c);
pf.events.deinit(gpa);
pf.pty_out.deinit(gpa);
pf.tag_snap.deinit(gpa);
@@ -128,11 +144,12 @@ pub fn clip(n: usize) u32 {
pub fn dotOf(pane: *Pane) State.Range {
const text = bodyOf(pane);
- const head = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
+ // In runes, as every address is (modal.runeStart).
+ const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) });
if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) };
- const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
+ const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) });
var hi = @max(head, anchor);
- if (hi < text.len) hi = modal.nextGrapheme(text, hi);
+ if (hi < text.len) hi = modal.nextRune(text, hi);
return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) };
}
@@ -140,9 +157,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void {
const text = bodyOf(pane);
const q0 = @min(@as(usize, r.q0), text.len);
const q1 = @max(q0, @min(@as(usize, r.q1), text.len));
- const a = modal.positionAt(text, q0);
+ const a = modal.runePositionAt(text, q0);
pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
- const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0);
+ const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0);
pane.body.cur_row = @intCast(h.row);
pane.body.cur_col = @intCast(h.col);
pane.body.cur_pinned = true;
@@ -246,10 +263,11 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 {
const d = dotOf(pane);
break :sel @max(d.q0, d.q1) - d.q0;
},
- .addr, .dot => range_len,
+ .addr => if (pf.addr_failed) 0 else range_len,
+ .dot => range_len,
.limit => if (pf.limit == null) 0 else range_len,
.dirty, .mark, .scroll => 2,
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
// What the next read would answer, which a client can see without
// parking on it.
.event => if (pf.events.peek()) |record| record.len else 0,
@@ -304,11 +322,12 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
break :tag tree.stagedReply(p, req);
},
.ctl => ctl.readPane(p, req, pane),
- .addr => addr: {
- if (pf.addr_failed) break :addr tree.failText(req.tag, E.INVAL, e_addr_failed);
+ // A failed address reads empty, as an unset limit does: there is
+ // none. data and xdata are what refuse.
+ .addr => readRange(p, req, if (pf.addr_failed) null else addr: {
clampAddr(pf, bodyOf(pane).len);
- break :addr readRange(p, req, pf.addr);
- },
+ break :addr pf.addr;
+ }),
.dot => readRange(p, req, dotOf(pane)),
.limit => readRange(p, req, pf.limit),
.data => readData(req, id, pane, pf, false),
@@ -317,7 +336,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.dirty => readFlag(p, req, dirtyOf(pane)),
.mark => readFlag(p, req, !pf.nomark),
.scroll => readFlag(p, req, !pf.noscroll),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => 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),
@@ -369,7 +388,7 @@ fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Rep
const q0: usize = pf.addr.q0;
const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len;
var end = @min(hi, q0 +| req.size);
- end = @max(q0, modal.graphemeStart(text, end));
+ end = @max(q0, modal.runeStart(text, end));
pf.addr.q0 = clip(end);
if (!stop_at_end) pf.addr.q1 = clip(end);
if (pane.file == null) return .{ .tag = req.tag };
@@ -409,7 +428,9 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
.data, .xdata => writeData(p, req, pane),
.sel => writeSel(p, req, pane),
.dirty, .mark, .scroll => writeFlag(p, req, pane, file),
- .look, .exec => ctl.command(p, req, pane.serial, file == .exec),
+ .look, .exec => ctl.command(p, req, pane.serial, file == .exec, false),
+ // A click in its tag: never an event reader's to hear (cols.writeExec).
+ .tagexec => ctl.command(p, req, pane.serial, true, true),
.event => events.writeEvent(p, req, id),
.errors => if (appendErrors(p, id, req.data)) |took|
.{ .tag = req.tag, .written = @intCast(took) }
@@ -522,8 +543,13 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
};
const range = r orelse {
if (file == .addr) pf.addr_failed = true;
+ if (file == .limit) if (pf.limit_truncated) |kept| {
+ pf.limit = kept;
+ pf.limit_truncated = null;
+ };
return tree.failText(req.tag, E.INVAL, a.err);
};
+ if (file == .limit) pf.limit_truncated = null;
switch (file) {
.addr => {
pf.addr = range;
@@ -565,10 +591,18 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
/// Writing `name` renames the buffer: a relative name resolves against the
/// pane's directory, as the tag's name field does.
+/// acme's refusal of a name with a blank or a control character in it
+/// (editors/acme/xfid.c:650-652). A blank inside a name is taken here, as
+/// pardes names files with spaces; one at either end is refused, not
+/// quietly cut off, so the name a script wrote is the name it gets.
+const e_name_char = "bad character in file name";
+
fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
- const name = std.mem.trim(u8, req.data, " \t\r\n");
+ // One line: its newline ends it, as `echo` writes it.
+ const name = if (std.mem.endsWith(u8, req.data, "\n")) req.data[0 .. req.data.len - 1] else req.data;
if (name.len == 0) return Reply.fail(req.tag, E.INVAL);
- for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL);
+ for (name) |c| if (c < ' ') return tree.failText(req.tag, E.INVAL, e_name_char);
+ if (name[0] == ' ' or name[name.len - 1] == ' ') return tree.failText(req.tag, E.INVAL, e_name_char);
if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal())
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
@@ -638,6 +672,12 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
return null;
};
exec.placeNew(p, id, id, free, .errors);
+ // No column had room for it (it closes at the step's end): the text is
+ // logged instead, a msg a line, so it is not lost and nothing fails.
+ if (p.unplaced.isSet(free)) {
+ var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n');
+ while (lines.next()) |line| events.noteMessage(p, pane.serial, line[0..@min(line.len, pardes.Messages.LoggedMessage.cap)]);
+ }
return text.len;
}
@@ -683,7 +723,10 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
// is evaluated from where the last one left off, as with `>>`.
// `0` (or `,`) is how to start over. See State.addr.
.addr => {},
- .limit => pf.limit = null,
+ .limit => {
+ pf.limit_truncated = pf.limit;
+ pf.limit = null;
+ },
.dot => if (fileOf(pane) != null) setDot(pane, .{}),
else => {},
}
@@ -858,7 +901,7 @@ test "data and xdata read from addr, move it, and write through it" {
try testing.expectEqual(@as(u32, 4), p.panes[0].?.fs.addr.q0);
}
-test "data never splits a grapheme, in either direction" {
+test "data never splits a rune, in either direction" {
const gpa = testing.allocator;
const p = try withFile(gpa, "\u{00e9}x\n");
defer p.deinit();
@@ -1060,6 +1103,9 @@ test "name reads the file name and writing it promotes a scratch without touchin
try testing.expectEqualStrings("existing target\n", target);
for ([_][]const u8{ "", "\n", "bad\x01name\n" }) |bad|
try testing.expectEqual(E.INVAL, wr(p, name, bad).errno());
+ // A blank at either end is refused as acme refuses one, not cut off.
+ for ([_][]const u8{ "trailing.zig \n", " leading.zig\n", "tab\t.zig\n" }) |bad|
+ try testing.expectEqualStrings("bad character in file name", wr(p, name, bad).reply.ename);
try testing.expectEqualStrings(path, pane.file.?.path);
try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status);
const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory});
@@ -1108,11 +1154,15 @@ test "relative names use the file directory and can name a new target" {
try testing.expect(dirtyOf(pane));
}
-test "relative names follow inherited scratch and virtual directories" {
+test "relative names follow a pane/new scratch's session directory and virtual directories" {
const p = try withFile(testing.allocator, "source body\n");
defer p.deinit();
const source = p.panes[0].?;
- try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/project/src/source.zig\n").reply.status);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/elsewhere/source.zig\n").reply.status);
+ // pane/new's scratch is the session's, not the active pane's.
+ const session = "/project/src";
+ @memcpy(p.session_dir[0..session.len], session);
+ p.session_dir_len = session.len;
const serial = try th.newPane(p);
const scratch = p.panes[p.paneBySerial(serial).?].?;
const name = Node.of(serial, .name);
@@ -1205,3 +1255,59 @@ test "setattr truncation empties the body and answers fresh attributes" {
const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) });
try testing.expectEqual(@as(u64, 9), noop.reply.attr.size);
}
+
+test "addresses are in runes: a combining mark and a lone \\r are places of their own" {
+ const gpa = testing.allocator;
+ // e, U+0301 (two bytes, 1-2), x, \r, \n
+ const p = try withFile(gpa, "e\u{0301}x\r\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const addr_node = Node.of(serial, .addr);
+ const dot = Node.of(serial, .dot);
+ const Case = struct { expr: []const u8, q0: u32, q1: u32 };
+ for ([_]Case{
+ .{ .expr = "#1,#3", .q0 = 1, .q1 = 3 }, // the mark alone
+ .{ .expr = "#2", .q0 = 1, .q1 = 1 }, // inside it: back to its start
+ .{ .expr = "#4,#5", .q0 = 4, .q1 = 5 }, // the \r alone
+ .{ .expr = "1:2", .q0 = 1, .q1 = 1 }, // line:col the same
+ .{ .expr = "1:3", .q0 = 1, .q1 = 1 },
+ .{ .expr = "0/\xCC/", .q0 = 1, .q1 = 3 }, // a match inside a rune covers it
+ }) |c| {
+ try testing.expectEqual(Status.ok, wr(p, addr_node, c.expr).reply.status);
+ try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0);
+ try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1);
+ }
+ // Copied to dot, it reads back the same.
+ _ = wr(p, addr_node, "#1,#3");
+ try testing.expectEqual(Status.ok, wr(p, dot, " 1 3 ").reply.status);
+ p.sync();
+ try testing.expectEqualStrings(" 1 3 ", rd(p, dot, 0, 100).bytes);
+ _ = wr(p, addr_node, "#4,#5");
+ try testing.expectEqualStrings("\r", rd(p, Node.of(serial, .xdata), 0, 100).bytes);
+}
+
+test "a limit write that fails keeps the limit a truncation cleared before it" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "one two\n");
+ defer p.deinit();
+ const limit = Node.of(serialOf(p), .limit);
+ _ = wr(p, limit, "#0,#3");
+ _ = call(p, .{ .tag = 1, .op = .setattr, .node = limit, .truncate = true });
+ try testing.expectEqual(Status.err, wr(p, limit, "/nomatch/").reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, limit, 0, 100).bytes);
+ // A truncation with no write after it clears it.
+ _ = call(p, .{ .tag = 2, .op = .setattr, .node = limit, .truncate = true });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = limit });
+ try testing.expectEqualStrings("", rd(p, limit, 0, 100).bytes);
+}
+
+test "a pane's tagexec runs a word as a click in its tag, and reads back what it touched" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Msg from the tag\n").reply.status);
+ try testing.expect(th.logHas(p, "from the tag"));
+ var want: [16]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, Node.of(serial, .tagexec), 0, 64).bytes);
+}
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 393516ce..4009af49 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -25,7 +25,25 @@ fn dimension(word: []const u8) ?u16 {
if (word.len == 0 or word.len > 5) return null;
for (word) |c| if (c < '0' or c > '9') return null;
const n = std.fmt.parseInt(u16, word, 10) catch return null;
- return if (n == 0) null else n;
+ return if (n == 0 or n > winsize_max) null else n;
+}
+
+/// The fewest rows a winsize write gives a terminal's pty.
+pub const pty_rows_min = 2;
+
+/// As the root ctl's `size` (4096 a side).
+const winsize_max = 4096;
+const e_winsize_range = std.fmt.comptimePrint("invalid winsize: out of range, 1x1 to {d}x{d}", .{ winsize_max, winsize_max });
+
+/// A winsize whose numbers are numbers but not sizes: said as such.
+fn outOfRange(line: []const u8) bool {
+ var words = std.mem.tokenizeAny(u8, line, " \t");
+ if (!std.mem.eql(u8, words.next() orelse return false, "winsize")) return false;
+ var n: u8 = 0;
+ while (words.next()) |w| : (n += 1) {
+ _ = std.fmt.parseInt(u32, w, 10) catch return false;
+ }
+ return n == 2;
}
fn signalNamed(word: []const u8) ?pardes.PtySignal {
@@ -43,7 +61,7 @@ pub fn writeCtl(p: *Pardes, req: Req, id: usize) Reply {
while (it.next()) |raw| {
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0) continue;
- if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, e_bad_pty_ctl);
+ if (!verb(p, id, line, apply)) return tree.failText(req.tag, E.INVAL, if (outOfRange(line)) e_winsize_range else e_bad_pty_ctl);
}
}
return .{ .tag = req.tag, .written = @intCast(req.data.len) };
@@ -56,7 +74,9 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
switch (v) {
.winsize => {
const cols = dimension(words.next() orelse return false) orelse return false;
- const rows = dimension(words.next() orelse return false) orelse return false;
+ // Two rows at least, as a pane is laid out: a one-row pty
+ // loses its prompt's input mark and reads busy for ever.
+ const rows = @max(pty_rows_min, dimension(words.next() orelse return false) orelse return false);
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
@@ -72,7 +92,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
if (words.next() != null) return false;
if (pane.cwdSlice().len > pardes.effect_path_cap) return false;
if (!apply) return true;
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(pane.cwdSlice()) } });
+ p.emitSpawn(id, pane.serial, pane.cwdSlice());
},
}
return true;
@@ -189,7 +209,12 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
// marks: a nested shell's prompt (ssh, a shell with its own
// integration) looks like an empty prompt to ghostty but is not the
// shell this run knows.
- answer(p, slot, "busy", .{});
+ // Naming what runs, where the host can tell (Linux).
+ var name: [64]u8 = undefined;
+ if (p.hostForegroundName(id, &name)) |program|
+ answer(p, slot, "busy: {s} is running", .{program})
+ else
+ answer(p, slot, "busy", .{});
} else {
// Sent now if the shell is at its prompt, or at its first prompt
// when it has not drawn one yet (noteMarks).
@@ -433,7 +458,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, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
try testing.expect(th.nameAt(files, "pty") == null);
try testing.expectEqual(E.NOENT, look_up(p, dir, "pty_ctl").errno());
@@ -454,7 +479,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, 18), files.len);
+ try testing.expectEqual(@as(usize, 19), files.len);
try testing.expect(th.nameAt(files, "pty").?.dir);
const inside = th.dirents(rdir(p, Node.of(serial, .pty), 0).bytes, &buf);
@@ -528,10 +553,7 @@ test "every pty/ctl verb, and every refusal" {
"winsize", // no arguments
"winsize 80", // one argument
"winsize 80 24 extra", // three
- "winsize 0 24", // zero is "unknown", never a width
- "winsize 80 0",
"winsize -1 24", // not a decimal
- "winsize 999999 24", // wider than a u16
"sig", // no name
"sig INT TERM", // two
"sig SIGINT",
@@ -553,6 +575,13 @@ test "every pty/ctl verb, and every refusal" {
try testing.expect(a.signal == null);
try testing.expect(!a.spawned);
}
+ // Numbers, but no size: said as out of range, the bound size's.
+ for ([_][]const u8{ "winsize 0 24", "winsize 80 0", "winsize 0 0", "winsize 4097 24", "winsize 999999 24" }) |bad| {
+ const a = wr(p, ctl, bad);
+ try testing.expectEqual(E.INVAL, a.errno());
+ try testing.expectEqualStrings(e_winsize_range, a.reply.ename);
+ try testing.expect(a.winsize == null);
+ }
const spaced = wr(p, ctl, "\n winsize 90 20 \n\n");
try testing.expectEqual(Status.ok, spaced.reply.status);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index baf8c772..ed0e0352 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -66,7 +66,10 @@ pub const e_bad_event = "bad event syntax";
/// A second open reading a file whose reads consume: rio's word for it
/// (windows/rio/xfid.c:25), which 9ns turns into EBUSY.
pub const e_in_use = "file in use";
-pub const e_shut_down = "window shut down";
+/// A read held on a pane's event or pty/data that the pane closed under.
+/// acme says "window shut down" (xfid.c:1005), which 9ns reads as EIO;
+/// these words read as ENOENT, what every other file of a gone pane gives.
+pub const e_shut_down = "no such pane: its window shut";
/// Tremove closes a pane; nothing else in the tree is created or destroyed by
/// the protocol, and wstat stays a truncation. Making a pane is an open of
@@ -82,21 +85,21 @@ pub const features: cloud9.fs.Features = .{ .remove = true, .create = true };
/// the mutation of its own step, so the layout and the surface are whole
/// under it -- and a step out there is never waiting on a write of its own,
/// so what parks here is never the syscall's own request.
-pub fn needsQuiet(req: Req) bool {
+pub fn needsQuiet(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
else => false,
};
}
/// `needsQuiet`, plus the open that makes a pane: what costs a frame and a
/// wake of the editor.
-pub fn changesPane(req: Req) bool {
+pub fn changesPane(p: *Pardes, req: Req) bool {
return switch (req.op) {
.write, .setattr => true,
.open => req.node == @intFromEnum(TopFile.new),
- .release => req.remove,
+ .release => req.remove or holdsLines(p, req),
.lookup, .getattr, .read, .readdir => false,
};
}
@@ -125,7 +128,20 @@ pub const Open = struct {
pty_data,
/// An open that may write a pane's ctl, and so hold its lock.
ctl,
+ /// An open of a file that takes lines of commands (look, exec, a
+ /// ctl, tagexec): its `pending` and `results`.
+ lines,
} = .{ .snapshot = null },
+ /// What a write to a file of command lines left unfinished: a last line
+ /// with no newline yet, or an Edit block still open. A write through a
+ /// mount is cut at the 9P message size, so one `echo` of many lines may
+ /// come as several writes, cut anywhere; the rest waits for the open's
+ /// next write, or its release, and never runs as a fragment.
+ pending: std.ArrayList(u8) = .empty,
+ /// The panes this open's last write touched, which a read of look or
+ /// exec on it answers, as /net/tcp/clone answers on the open that
+ /// asked; a fresh open holds the session's last, from before it opened.
+ results: Results = .{},
/// A read that found nothing yet (`.again`), kept to be answered when
/// this open's file has something, the way factotum keeps its log's
/// waiting reads and answers them on append (security/auth/factotum/
@@ -142,6 +158,16 @@ pub const Open = struct {
pub const Replay = struct { off: u64, bytes: []u8 };
+ pub const Results = struct {
+ list: [pardes.MAX_PANES]u32 = undefined,
+ len: u8 = 0,
+ /// Written on: reads are a stream from the write on, whatever
+ /// offset they come at, since a shell's `exec 3<>look` shares one
+ /// offset between the write and the read (as pty/run does).
+ wrote: bool = false,
+ read: usize = 0,
+ };
+
/// `asker` is the connection the read came on, which only the listener
/// knows.
pub const Held = struct { asker: *anyopaque, req: Req, ticket: cloud9.fs.Ticket };
@@ -154,8 +180,9 @@ pub const Open = struct {
gpa.free(run.output);
gpa.free(run.line);
},
- .event, .pty_data, .ctl => {},
+ .event, .pty_data, .ctl, .lines => {},
}
+ o.pending.deinit(gpa);
if (o.replay) |r| gpa.free(r.bytes);
o.* = .{};
}
@@ -191,6 +218,7 @@ pub const TopFile = enum(u5) {
layout,
tag,
col,
+ tagexec,
pub fn fileName(f: TopFile) []const u8 {
return if (f == .root) "/" else @tagName(f);
@@ -199,7 +227,7 @@ pub const TopFile = enum(u5) {
pub fn mode(f: TopFile) u16 {
return switch (f) {
.root, .pane, .col => 0o755,
- .look, .exec, .log, .focus, .ctl, .tag => 0o666,
+ .look, .exec, .log, .focus, .ctl, .tag, .tagexec => 0o666,
.README, .index, .status, .screen, .listeners, .new, .commands, .layout => 0o444,
};
}
@@ -231,6 +259,9 @@ pub const PaneFile = enum(u5) {
event,
look,
exec,
+ /// A word run as a click in the pane's tag, as /tagexec is in the
+ /// workspace's and col/<n>/exec in a column's.
+ tagexec,
pty,
pty_ctl,
pty_status,
@@ -468,10 +499,14 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
.dir = c.file == .dir,
.mode = switch (c.file) {
.dir => 0o755,
- .ctl, .exec => 0o222,
- .tag => 0o666,
+ .ctl => 0o222,
+ .tag, .exec => 0o666,
+ },
+ .size = switch (c.file) {
+ .tag => cols.headerText(p, c.serial).?.len + 1,
+ .exec => ctl.resultsLen(p),
+ else => 0,
},
- .size = if (c.file == .tag) cols.headerText(p, c.serial).?.len + 1 else 0,
.mtime = p.fs.started,
.append = c.file == .tag,
};
@@ -513,7 +548,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 {
.index => pane.indexLen(p),
.README => fs.help.len,
.status => ctl.statusLen(p),
- .look, .exec => ctl.resultsLen(p),
+ .look, .exec, .tagexec => ctl.resultsLen(p),
.log => events.logLen(&p.fs.log),
.listeners => listeners: {
var buf: [screen.listeners_cap]u8 = undefined;
@@ -618,7 +653,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply {
.root => {
// The guide, then what describes the session, then the
// files that act, then the directories.
- inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.pane, TopFile.col }) |t| {
+ inline for (.{ TopFile.README, TopFile.index, TopFile.status, TopFile.focus, TopFile.ctl, TopFile.commands, TopFile.look, TopFile.exec, TopFile.log, TopFile.screen, TopFile.listeners, TopFile.layout, TopFile.tag, TopFile.tagexec, TopFile.pane, TopFile.col }) |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");
@@ -680,16 +715,26 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// pane it made, so reading it twice answers the same one and
// closing it leaves the pane alone.
.new => {
- const slot = p.freeSlot() orelse return failText(req.tag, E.NFILE, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no pane slots");
+ const slot = p.freeSlot() orelse return failText(req.tag, E.NOSPC, std.fmt.bufPrint(&p.fs.ename, "pane/new: " ++ pardes.Messages.no_pane_slots, .{pardes.MAX_PANES}) catch "no space for a pane");
+ p.fs.no_pane_slot = false;
+ // Refused, the open's err is the one record of it (the
+ // one rule): no msg besides.
+ p.fs.capturing = true;
p.newScratchFor(p.active, .fs);
+ p.fs.capturing = false;
+ p.fs.failure_len = 0;
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
+ // No room in its column: it closes at the step's end.
+ if (p.unplaced.isSet(slot)) return failText(req.tag, E.NOSPC, p.fs.ename[0..p.fs.no_pane_slot_len]);
return .{ .tag = req.tag, .handle = made.serial };
},
.screen => .{ .snapshot = null },
.log => .{ .log = .{} },
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
},
- .col => |c| return if (layout.columnBySerial(p, c.serial) != null) .{ .tag = req.tag } else Reply.fail(req.tag, E.NOENT),
+ .col => |c| if (layout.columnBySerial(p, c.serial) == null)
+ return Reply.fail(req.tag, E.NOENT)
+ else if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
.pane => |t| what: {
const pn = p.panes[p.paneBySerial(t.serial) orelse return Reply.fail(req.tag, E.NOENT)].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
@@ -706,7 +751,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
1, 2 => .ctl, // OWRITE, ORDWR
else => return .{ .tag = req.tag },
},
- else => return .{ .tag = req.tag },
+ else => if (linesOpen(target, req)) .lines else return .{ .tag = req.tag },
};
},
};
@@ -715,6 +760,10 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
} else return Reply.fail(req.tag, E.NFILE);
const o = &p.fs.opens[i];
o.* = .{ .node = req.node, .what = what };
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results.len = p.fs.results_len;
+ }
switch (o.what) {
// A screen freezes at open; a terminal body at its first read.
.snapshot => |*bytes| if (pane_of == null) {
@@ -734,7 +783,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
pn.fs.event_reader = pn.fs.event_reader or reader;
},
.pty_data => pane_of.?.fs.pty_reader = true,
- .run, .ctl => {},
+ .run, .ctl, .lines => {},
}
return .{ .tag = req.tag, .handle = @intCast(i + 1) };
}
@@ -771,9 +820,21 @@ fn releaseHandle(p: *Pardes, req: Req) void {
p.panes[id].?.fs.tag_rewrite = false;
p.panes[id].?.fs.tag_held_newline = false;
};
+ // `: > limit` clears it for good once its open goes.
+ if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
+ p.panes[id].?.fs.limit_truncated = null;
+ };
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;
+ // The last line an open wrote with no newline after it runs now.
+ if (o.pending.items.len > 0) {
+ const rest = p.scratch.allocator().dupe(u8, o.pending.items) catch "";
+ o.pending.clearRetainingCapacity();
+ const w: Req = .{ .tag = req.tag, .op = .write, .node = req.node, .handle = req.handle, .data = rest };
+ const reply = writeNow(p, w, t);
+ if (reply.status == .err) events.noteError(p, w, reply);
+ }
const of: ?*Pane = if (t == .pane) (if (p.paneBySerial(t.pane.serial)) |id| p.panes[id] else null) else null;
if (of) |pn| switch (o.what) {
.event => |reader| {
@@ -795,7 +856,7 @@ fn releaseHandle(p: *Pardes, req: Req) void {
.ctl => if (pn.fs.lock == req.handle) {
pn.fs.lock = null;
},
- .snapshot, .log => {},
+ .snapshot, .log, .lines => {},
};
o.deinit(p.gpa);
}
@@ -861,7 +922,7 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
break :index stagedReply(p, req);
},
.status => ctl.readStatus(p, req),
- .look, .exec => ctl.readResults(p, req),
+ .look, .exec, .tagexec => ctl.readResults(p, req),
.log => events.readLog(p, req),
.screen => screen.readSnapshot(p, req, null),
.listeners => screen.readListeners(p, req),
@@ -892,25 +953,92 @@ fn readFile(p: *Pardes, req: Req, target: Target) Reply {
// An event or pty/data read the pane closed under: acme's
// answer to the same (editors/acme/xfid.c:1005).
if (openOf(p, req)) |o| if (o.what == .event or o.what == .pty_data)
- return failText(req.tag, E.IO, e_shut_down);
+ return failText(req.tag, E.NOENT, e_shut_down);
return Reply.fail(req.tag, E.NOENT);
};
const pn = p.panes[id].?;
if (t.file.inPty() and !pn.isTerminal()) return Reply.fail(req.tag, E.NOENT);
return pane.read(p, req, id, pn, t.file);
},
- .col => |c| return if (c.file == .tag) cols.read(p, req, c.serial) else Reply.fail(req.tag, E.PERM),
+ .col => |c| return switch (c.file) {
+ .tag => cols.read(p, req, c.serial),
+ .exec => ctl.readResults(p, req),
+ else => Reply.fail(req.tag, E.PERM),
+ },
}
}
+/// The files whose writes are lines of commands, run as each line is whole.
+fn linesFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .ctl or f == .tagexec,
+ .col => |c| c.file == .ctl or c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .ctl or t.file == .tagexec,
+ };
+}
+
+/// The ones of those a read answers with the panes a write touched.
+fn resultsFile(target: Target) bool {
+ return switch (target) {
+ .top => |f| f == .look or f == .exec or f == .tagexec,
+ .col => |c| c.file == .exec,
+ .pane => |t| t.file == .look or t.file == .exec or t.file == .tagexec,
+ };
+}
+
+/// An open that keeps lines or results: any that may write one of those
+/// files, and any that may read a look or exec.
+fn linesOpen(target: Target, req: Req) bool {
+ if (!linesFile(target)) return false;
+ return req.omode & 3 != 0 or resultsFile(target);
+}
+
+/// A release that will run a line its open held back.
+fn holdsLines(p: *Pardes, req: Req) bool {
+ const o = openOf(p, req) orelse return false;
+ return o.pending.items.len > 0;
+}
+
+/// A line held back longer than this is refused rather than kept growing.
+const pending_cap = 1 << 20;
+
fn write(p: *Pardes, req: Req, target: Target) Reply {
+ const o = (if (linesFile(target)) openOf(p, req) else null) orelse return writeNow(p, req, target);
+ if (o.what != .lines and o.what != .ctl) return writeNow(p, req, target);
+ // A clicked line holds no control character: refused at once, not held
+ // to fail unseen at the close.
+ if (resultsFile(target)) for (req.data) |c| if (c < ' ' and c != '\t' and c != '\n' and c != '\r') return Reply.fail(req.tag, E.INVAL);
+ o.pending.appendSlice(p.gpa, req.data) catch return Reply.fail(req.tag, E.NOMEM);
+ const end = ctl.completeEnd(p, o.pending.items);
+ if (end == 0) {
+ if (o.pending.items.len <= pending_cap) return .{ .tag = req.tag, .written = @intCast(req.data.len) };
+ o.pending.clearRetainingCapacity();
+ return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB");
+ }
+ const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM);
+ const rest = o.pending.items.len - end;
+ std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]);
+ o.pending.shrinkRetainingCapacity(rest);
+ var w = req;
+ w.data = whole;
+ var reply = writeNow(p, w, target);
+ if (resultsFile(target)) {
+ @memcpy(o.results.list[0..p.fs.results_len], p.fs.results[0..p.fs.results_len]);
+ o.results = .{ .list = o.results.list, .len = p.fs.results_len, .wrote = true };
+ }
+ if (reply.status == .ok) reply.written = @intCast(req.data.len);
+ return reply;
+}
+
+fn writeNow(p: *Pardes, req: Req, target: Target) Reply {
switch (target) {
.top => |f| return switch (f) {
- .look, .exec => ctl.command(p, req, null, f == .exec),
+ .look, .exec => ctl.command(p, req, null, f == .exec, false),
.log => events.writeLog(p, req),
.focus => ctl.writeFocus(p, req),
.ctl => ctl.writeRoot(p, req),
.tag => cols.write(p, req, null),
+ .tagexec => cols.writeExec(p, req, null),
else => Reply.fail(req.tag, E.PERM),
},
.pane => |t| {
@@ -1069,13 +1197,13 @@ test "readdir lists the root and a pane directory without creating anything" {
@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 >= 10);
- for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "pane", "col", "os" }, 0..) |name, i| {
+ for ([_][]const u8{ "README", "index", "status", "focus", "ctl", "commands", "look", "exec", "log", "screen", "listeners", "layout", "tag", "tagexec", "pane", "col", "os" }, 0..) |name, i| {
try testing.expectEqualStrings(name, top[i].name);
- try testing.expectEqual(i >= 13, top[i].dir);
+ try testing.expectEqual(i >= 14, top[i].dir);
}
- try testing.expectEqual(@intFromEnum(TopFile.pane), top[13].node);
- try testing.expectEqual(@intFromEnum(TopFile.col), top[14].node);
- try testing.expectEqual(fs.os_root, top[15].node);
+ try testing.expectEqual(@intFromEnum(TopFile.pane), top[14].node);
+ try testing.expectEqual(@intFromEnum(TopFile.col), top[15].node);
+ try testing.expectEqual(fs.os_root, top[16].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);
@@ -1114,10 +1242,10 @@ test "readdir lists the root and a pane directory without creating anything" {
const dir = rdir(p, Node.of(serial, .dir), 0);
const files = th.dirents(dir.bytes, &buf);
- try testing.expectEqual(@as(usize, 17), files.len);
+ try testing.expectEqual(@as(usize, 18), files.len);
for ([_][]const u8{
"name", "body", "tag", "ctl", "addr", "dot", "limit", "data", "xdata",
- "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec",
+ "sel", "dirty", "mark", "scroll", "errors", "event", "look", "exec", "tagexec",
}, 0..) |name, i|
try testing.expectEqualStrings(name, files[i].name);
try testing.expect(th.nameAt(files, ".") == null);
@@ -1203,6 +1331,10 @@ test "opening /pane/new makes a pane and removing one closes it" {
const id = p.paneBySerial(serial).?;
try testing.expectEqualStrings("", p.panes[id].?.file.?.content);
+ // Its directory is the session's, not that of /test.txt, the pane
+ // that happened to have the keyboard.
+ var named: [4096]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&named, "{s}/+New", .{p.sessionDir()}), p.panes[id].?.file.?.path);
_ = wr(p, Node.of(serial, .body), "hi");
// A second open is a second pane, and releasing a clone fid leaves the
@@ -1310,14 +1442,19 @@ test "editor paths resolve to the same nodes the wire serves" {
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
+ // Tall enough that the column holds every pane at its tag and two rows,
+ // and wide enough that each tag, the session directory's +New, is one.
+ p.update(.{ .resize = .{ .cols = 400, .rows = pardes.MAX_PANES * 4 } });
const new = @intFromEnum(TopFile.new);
while (p.freeSlot() != null) try testing.expectEqual(Status.ok, call(p, .{ .tag = 1, .op = .open, .node = new }).reply.status);
var want: [64]u8 = undefined;
- const said = try std.fmt.bufPrint(&want, "no pane slots: {d} max", .{pardes.MAX_PANES});
+ const said = try std.fmt.bufPrint(&want, "no space for a pane: {d} max", .{pardes.MAX_PANES});
const refused = call(p, .{ .tag = 2, .op = .open, .node = new });
- try testing.expectEqual(E.NFILE, refused.errno());
+ try testing.expectEqual(E.NOSPC, refused.errno());
try testing.expect(std.mem.endsWith(u8, refused.reply.ename, said));
try testing.expect(th.logHas(p, said));
+ // Its err alone says it: no msg besides (the one rule).
+ try testing.expect(!th.logHas(p, "New: no space"));
// A look that would open a pane fails its write, and says the same.
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1335,3 +1472,113 @@ test "at the pane cap, pane/new, look and New each say so, and look reads back e
const made = th.wr(p, ctl_node, "New\n");
try testing.expect(std.mem.indexOf(u8, made.reply.ename, said) != null);
}
+
+test "a command line cut across writes runs once whole, and the last runs at release; an Edit block waits for its end" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ try testing.expect(h != 0);
+ for ([_][]const u8{ "Msg he", "llo\nMsg wor", "ld\nMsg tail" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = h, .data = piece }).reply.status);
+ try testing.expect(th.logHas(p, " hello\n") and th.logHas(p, " world\n"));
+ try testing.expect(!th.logHas(p, " he\n") and !th.logHas(p, " tail\n"));
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = h, .opened = true });
+ try testing.expect(th.logHas(p, " tail\n"));
+ // An Edit block's text lines come in later writes on the same open.
+ const serial = serialOf(p);
+ const ctl_node = Node.of(serial, .ctl);
+ const c = call(p, .{ .tag = 4, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ for ([_][]const u8{ "Edit ,a\n", "one\ntw", "o\n", ".\n" }) |piece|
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 5, .op = .write, .node = ctl_node, .handle = c, .data = piece }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expectEqualStrings("x\none\ntwo\n", p.panes[0].?.file.?.content);
+}
+
+test "look and exec read back what their own open's write touched; a fresh open reads the session's last" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const exec = @intFromEnum(TopFile.exec);
+ const first = p.panes[0].?.serial;
+ const mine = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ const theirs = call(p, .{ .tag = 2, .op = .open, .node = exec, .omode = 2 }).reply.handle;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = exec, .handle = mine, .data = "Tty\n" });
+ const made = p.panes[p.active].?.serial;
+ try testing.expect(made != first);
+ _ = call(p, .{ .tag = 4, .op = .write, .node = exec, .handle = theirs, .data = "Msg hi\n" });
+ var want: [16]u8 = undefined;
+ // Read at the offset the write left, as a shell's `exec 3<>` does.
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{made}), call(p, .{ .tag = 5, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ try testing.expectEqualStrings("", call(p, .{ .tag = 6, .op = .read, .node = exec, .handle = mine, .off = 4, .size = 64 }).bytes);
+ const said = p.panes[p.active].?.serial;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 7, .op = .read, .node = exec, .handle = theirs, .size = 64 }).bytes);
+ const fresh = call(p, .{ .tag = 8, .op = .open, .node = exec }).reply.handle;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{said}), call(p, .{ .tag = 9, .op = .read, .node = exec, .handle = fresh, .size = 64 }).bytes);
+ for ([_]u32{ mine, theirs, fresh }) |hh| _ = call(p, .{ .tag = 10, .op = .release, .node = exec, .handle = hh, .opened = true });
+}
+
+test "no placement leaves a pane shorter than its tag and two rows; a full column refuses the next" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const new = @intFromEnum(TopFile.new);
+ var made: usize = 1;
+ const refused = while (made < pardes.MAX_PANES) : (made += 1) {
+ const a = call(p, .{ .tag = 1, .op = .open, .node = new });
+ if (a.reply.status == .err) break a;
+ } else return error.NeverRefused;
+ try testing.expectEqual(E.NOSPC, refused.errno());
+ try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "no space for a pane in that column") != null);
+ p.sync();
+ var count: usize = 0;
+ for (p.panes) |slot| count += @intFromBool(slot != null);
+ try testing.expectEqual(made, count); // the refused one is gone
+ for (0..p.ncol) |c| for (p.col_panes[c][0..p.col_n[c]]) |k| {
+ if (p.col_n[c] > 1 and !p.panes[k].?.collapsed)
+ try testing.expect(p.rects[k].h >= p.panes[k].?.tag_rows + pardes.layout.min_body_rows);
+ };
+}
+
+test "an Edit block still open when its open closes fails there with an err and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit ,x/b/{\ni/[/\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "unmatched"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text still open when its open closes fails there too and changes nothing" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const ctl_node = Node.of(serialOf(p), .ctl);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = ctl_node, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = ctl_node, .handle = c, .data = "Edit $a\nhalf of a block\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = ctl_node, .handle = c, .opened = true });
+ try testing.expect(th.logHas(p, "not ended by a . line"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "an a, c or i text cut short through exec is an err in the log, not a msg" {
+ const p = try th.withFile(testing.allocator, "abc\n");
+ defer p.deinit();
+ const exec = Node.of(serialOf(p), .exec);
+ const c = call(p, .{ .tag = 1, .op = .open, .node = exec, .omode = 1 }).reply.handle;
+ _ = call(p, .{ .tag = 2, .op = .write, .node = exec, .handle = c, .data = "Edit $a\nhalf\n" });
+ _ = call(p, .{ .tag = 3, .op = .release, .node = exec, .handle = c, .opened = true });
+ var want: [64]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "err {d} exec: Edit: a, c or i text not ended", .{serialOf(p)})));
+ try testing.expect(!th.logHas(p, "msg 1 Edit: a, c or i"));
+ try testing.expectEqualStrings("abc\n", p.panes[0].?.file.?.content);
+}
+
+test "a pane made from pane/new goes last in its column, even when the last pane has no room to halve" {
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ for (0..3) |_| {
+ const made = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }).reply.handle;
+ p.sync();
+ try testing.expectEqual(made, p.panes[p.col_panes[0][p.col_n[0] - 1]].?.serial);
+ }
+}
diff --git a/src/pardes.zig b/src/pardes.zig
index 1f2c3175..72a90244 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -267,7 +267,9 @@ pub const MAX_TAG_ROWS = 8;
pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max;
pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1;
pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px;
-pub const MAX_COLS = 6;
+/// Columns a session holds: 16 on a desktop (each costs about 8 KB of state,
+/// a column tag being most of it), 6 on the board.
+pub const MAX_COLS = if (platform == .esp32p4) 6 else 16;
const column_weight_unit = layout.column_weight_unit;
const max_column_weight = layout.max_column_weight;
pub const MAX_JUMPS = 64;
@@ -471,6 +473,7 @@ test {
_ = @import("body_layer.zig");
_ = @import("draw.zig");
_ = @import("tag_layer.zig");
+ _ = @import("dump.zig"); // its tests ran nowhere before
_ = @import("Presentation.zig");
_ = @import("animation.zig");
}
@@ -1653,13 +1656,30 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
try std.testing.expectEqualStrings("\x1b[200~print(1)\x1b[201~", Sent.said(1));
p.update(.{ .output = .{ .pane = 1, .bytes = "print(1)" } });
Sent.drain(p);
+ // Python's echo only restarts a short wait: the Enter goes once the
+ // echo of the whole paste has gone quiet.
+ try std.testing.expectEqualStrings("", Sent.said(1));
+ p.now_ns = p.panes[1].?.repl_enter_due_ns;
+ p.update(.tick);
+ Sent.drain(p);
try std.testing.expectEqualStrings("\r", Sent.said(1));
+ // A paste of more than one line gets the blank line a block gets.
+ _ = exec.executeFrom(p, 0, "print(11)\nprint(22)", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("\x1b[200~print(11)\nprint(22)\x1b[201~", Sent.said(1));
+ p.now_ns = p.panes[1].?.repl_enter_due_ns;
+ p.update(.tick);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("\r\r", Sent.said(1));
// Nothing answering, it goes when its wait is up.
_ = exec.executeFrom(p, 0, "print(2)", true);
- p.stepped_ns += 300 * std.time.ns_per_ms;
+ p.update(.tick); // on the clock: not yet, however old core time is
+ Sent.drain(p);
+ try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~"));
+ p.now_ns = p.panes[1].?.repl_enter_due_ns; // the clock reaches its wait
p.update(.tick);
Sent.drain(p);
- try std.testing.expect(std.mem.endsWith(u8, Sent.said(1), "\x1b[200~print(2)\x1b[201~\r"));
+ try std.testing.expectEqualStrings("\r", Sent.said(1));
p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?2004l" } });
Sent.len[1] = 0;
// A block left open by its indented last line gets the blank line that
@@ -1714,6 +1734,7 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n");
try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status);
try std.testing.expectEqualStrings("print(5)\r", answered.pty());
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} a\n", .{file.serial})));
}
_ = exec.executeFrom(p, 0, "print(3)", true);
p.update(.{ .key = .{ .cp = Key.escape } });
@@ -1804,6 +1825,10 @@ test "Tty+fish, one word a tag can hold, opens a terminal on that shell" {
try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null);
try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell"));
try std.testing.expect(caller.shell == null);
+ // A directory passes access(X_OK); it is no shell either.
+ try std.testing.expect(p.executeBuiltinLine(0, "Tty /etc"));
+ try std.testing.expect(p.active == before);
+ try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "Tty: not a shell: /etc is a directory") != null);
// Only a word that says so splits at `+`: `Dump+x.zon` is no Dump.
try std.testing.expect(!p.executeBuiltinLine(0, "Dump+x.zon"));
try std.testing.expect(!p.executeBuiltinLine(0, "Msg+hello"));
@@ -2116,10 +2141,19 @@ test "owned cwd preserves long paths aliases and failed updates" {
const original = pane.cwdSlice();
try pane.setOwnedCwd(original);
try std.testing.expectEqualStrings(&path, pane.cwdSlice());
- const effects = p.effects_len;
+ // A terminal's directory may be as long as a path is (PATH_MAX): one
+ // past the effect's buffer goes out of line (spawn_dirs); only one past
+ // the host's path limit is too long.
const free = p.freeSlot().?;
- try std.testing.expectError(error.PathTooLong, p.newShell(free, path[0 .. effect_path_cap + 1]));
+ _ = try p.newShell(free, path[0..]);
+ try std.testing.expectEqualStrings(&path, p.spawn_dirs[free].?);
+ try p.removePane(free, null);
+ while (p.nextEffect()) |_| {}
+ var too_long: [limits.host_path_cap + 1]u8 = @splat('d');
+ too_long[0] = '/';
+ try std.testing.expectError(error.PathTooLong, p.newShell(free, &too_long));
try std.testing.expect(p.panes[free] == null);
+ const effects = p.effects_len;
exec.saveTo(p, 0, "file.txt");
try std.testing.expectEqual(effects, p.effects_len);
try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "path too long") != null);
@@ -2680,7 +2714,7 @@ test "a builtin that needs a pane reports capacity failure when every slot is fu
};
const help = std.meta.stringToEnum(Builtin, "Help") orelse return error.MissingHelpBuiltin;
exec.runBuiltin(p, help, 0, "", null);
- try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no pane slots") != null);
+ try std.testing.expect(std.mem.indexOf(u8, p.panes[0].?.msg[0..p.panes[0].?.msg_len], "help: no space for a pane") != null);
}
test "Font keeps requested and acknowledged faces as separate plain state" {
@@ -4272,6 +4306,19 @@ pub const Pardes = struct {
/// The column whose tag the running command came from, while it runs:
/// the column acme's commands take from their Text's col.
exec_column: ?usize = null,
+ /// Panes made this step that found no room in their column
+ /// (exec.placeNew): closed at the step's end, once whatever made them
+ /// is done with them.
+ unplaced: std.StaticBitSet(MAX_PANES) = .initEmpty(),
+ /// The running word came from the workspace's or a column's tag, which
+ /// have no directory of their own: it runs in the session's
+ /// (`sessionDir`), as acme's row and column tags do.
+ exec_header: bool = false,
+ session_dir: [limits.host_path_cap]u8 = undefined,
+ /// A spawn's directory too long for the effect's inline buffer, by pane
+ /// slot: the effect then carries none, and perform gives the host this.
+ spawn_dirs: [MAX_PANES]?[]u8 = @splat(null),
+ session_dir_len: ?u16 = null,
/// acme's activecol (dat.c:37), by column serial, 0 for none: the
/// column last typed in (acme.c:487), clicked in with the select button
/// (acme.c:659), dropped into by a grip (acme.c:640), or given a new
@@ -4547,7 +4594,7 @@ pub const Pardes = struct {
// text under the shell. The shell keeps the keyboard. A boot
// layout, like .classic's, not a placement.
if (p.boot == .tty) {
- const np = try p.newScratch(0, 1);
+ const np = try p.newScratch(0, 1, .scratch);
layout.insert(p, 0, 1, 1);
layout.splitBelow(p, 0, np);
}
@@ -4618,6 +4665,7 @@ pub const Pardes = struct {
if (p.last_dump) |d| gpa.free(d);
if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
if (p.chord_arg) |a| gpa.free(a);
+ for (p.spawn_dirs) |d| if (d) |dir| gpa.free(dir);
if (p.pipe.wait) |*wait| wait.deinit(gpa);
p.locations_cache.deinit(p.tree_sitter_gpa);
p.fs.deinit(gpa);
@@ -4760,20 +4808,29 @@ pub const Pardes = struct {
pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
std.debug.assert(p.panes[id] == null);
- if (cwd.len > effect_path_cap) return error.PathTooLong;
+ if (cwd.len > limits.host_path_cap) return error.PathTooLong;
const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
panes.Terminal.armShellSpawn(pane);
p.installPane(id, pane);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
+ p.emitSpawn(id, pane.serial, cwd);
return pane;
}
+ /// A child for pane `id` in `cwd`: a directory up to PATH_MAX, the ones
+ /// past the effect's inline buffer kept here for perform to give.
+ pub fn emitSpawn(p: *Pardes, id: usize, serial: u32, cwd: []const u8) void {
+ if (p.spawn_dirs[id]) |old| p.gpa.free(old);
+ p.spawn_dirs[id] = null;
+ if (cwd.len > effect_path_cap) p.spawn_dirs[id] = p.gpa.dupe(u8, cwd) catch null;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = serial, .cwd = .from(if (cwd.len > effect_path_cap) "" else cwd) } });
+ }
+
/// A command pane: a terminal whose child is `$SHELL -c line` rather
/// than an interactive shell, left showing its output and `exit N` when
/// it is done (exec.zig runCommand).
pub fn newCommand(p: *Pardes, id: usize, cwd: []const u8, line: []const u8) !*Pane {
std.debug.assert(p.panes[id] == null);
- if (cwd.len > effect_path_cap) return error.PathTooLong;
+ if (cwd.len > limits.host_path_cap) return error.PathTooLong;
const owned = try p.gpa.dupe(u8, line);
errdefer p.gpa.free(owned);
const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h);
@@ -4784,7 +4841,7 @@ pub const Pardes = struct {
// directory's commands reuse it; the child's own cd does not move it.
pane.setOwnedCwd(cwd) catch {};
p.installPane(id, pane);
- p.emit(.{ .spawn = .{ .pane = @intCast(id), .serial = pane.serial, .cwd = .from(cwd) } });
+ p.emitSpawn(id, pane.serial, cwd);
return pane;
}
@@ -4795,12 +4852,19 @@ pub const Pardes = struct {
return pane;
}
- fn newScratch(p: *Pardes, from_id: usize, free: usize) !*Pane {
+ fn newScratch(p: *Pardes, from_id: usize, free: usize, kind: exec.NewPane) !*Pane {
const src = p.panes[from_id] orelse return error.MissingPane;
const content = try p.gpa.dupe(u8, "");
errdefer p.gpa.free(content);
- const np = try panes.Output.open(p, free, paneDir(src), .{ .cmd = .New }, "", content);
- np.cwd = .{ .inherited = src };
+ // A terminal that has not said where it is yet (the boot layout's)
+ // has no directory: the scratch is the session's, <cwd>/+New. So is
+ // 9P's pane/new, and a New from a column's or the workspace's tag:
+ // no pane asked for it, whichever last had the keyboard (acme's new
+ // window has acme's directory).
+ const own = kind != .fs and !p.exec_header;
+ const dir = if (own) paneDir(src) else "";
+ const np = try panes.Output.open(p, free, if (dir.len == 0) p.sessionDir() else dir, .{ .cmd = .New }, "", content);
+ if (own) np.cwd = .{ .inherited = src };
np.body.cur_pinned = true;
return np;
}
@@ -4814,7 +4878,7 @@ pub const Pardes = struct {
/// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`).
pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void {
const free = p.freeSlot() orelse return p.reportError(from_id, "New", error.NoPaneSlots);
- _ = p.newScratch(from_id, free) catch return;
+ _ = p.newScratch(from_id, free, kind) catch return;
exec.placeNew(p, from_id, from_id, free, kind);
}
@@ -4822,7 +4886,14 @@ pub const Pardes = struct {
/// on its tag, for the next pane made to fill.
pub fn newColumn(p: *Pardes, from_id: usize) void {
const col = p.keyboardColumn(from_id) orelse return;
- const c = layout.insertColumn(p, col, false) orelse return;
+ // Said, not silently nothing: "no space" is 9ns's word for ENOSPC.
+ const c = layout.insertColumn(p, col, false) orelse {
+ var said: [96]u8 = undefined;
+ return p.reportFailure(from_id, if (p.ncol >= MAX_COLS)
+ std.fmt.bufPrint(&said, "Newcol: no space for a column: {d} max", .{MAX_COLS}) catch "Newcol: no space for a column"
+ else
+ "Newcol: no space for a column: this one is too narrow to split");
+ };
tagline.enterHeader(p, c);
}
@@ -4930,7 +5001,7 @@ pub const Pardes = struct {
pub fn shellBin(p: *const Pardes) []const u8 {
const selected = p.settings.shell.requested.get();
- return if (selected.len == 0) config.default_shell else selected;
+ return if (selected.len == 0) config.defaultShell() else selected;
}
pub fn acknowledgeShell(p: *Pardes, id: usize, executable: []const u8, prompt_marks: bool) void {
@@ -5111,6 +5182,11 @@ pub const Pardes = struct {
return ev;
}
+ pub fn hostForegroundName(p: *const Pardes, id: usize, buf: []u8) ?[]const u8 {
+ const f = p.host.vtable.fg_name orelse return null;
+ return f(p.host.ctx, @intCast(id), buf);
+ }
+
pub fn hostTtyTaken(p: *const Pardes, id: usize) bool {
const f = p.host.vtable.tty_taken orelse return false;
return f(p.host.ctx, @intCast(id));
@@ -5151,8 +5227,9 @@ pub const Pardes = struct {
var what: [limits.host_path_cap + 8]u8 = undefined;
const operation = std.fmt.bufPrint(&what, "Dump {s}", .{path}) catch "Dump";
p.reportError(p.active, operation, err);
- var buf: [256]u8 = undefined;
- const said = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch "Dump failed";
+ // In the words the message row has, so the write's err is the one
+ // record of it (its msg goes: 9p_io, dropMessage).
+ const said = if (p.panes[p.active]) |pane| pane.msg[0..pane.msg_len] else "Dump failed";
const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
@memcpy(p.fs.late_failure[0..kept.len], kept);
p.fs.late_failure_len = @intCast(kept.len);
@@ -5184,7 +5261,7 @@ pub const Pardes = struct {
switch (e) {
// The host reads the pane (its command, its shell) as it forks,
// so the slot must still hold the pane the spawn was for.
- .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, s.cwd.slice()) else {
+ .spawn => |s| if (if (p.panes[s.pane]) |pane| pane.serial != s.serial else true) {} else if (v.spawn) |f| f(p.host.ctx, s.pane, if (s.cwd.len == 0) (p.spawn_dirs[s.pane] orelse "") else s.cwd.slice()) else {
p.fallback.spawned[s.pane] = true;
},
// A pane with no child is silent: nothing invents output on its
@@ -5207,9 +5284,13 @@ pub const Pardes = struct {
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
- ctlfs.events.noteLog(p, .save, saved_pane);
const saved = if (saved_pane.file) |*file| file else return;
- if (saved.saved_revision != saved.revision or !saved.watch_after_save) return;
+ // A save the host could not do (saveFailed) is its err
+ // record alone, not a `save`.
+ if (saved.saved_revision != saved.revision) return;
+ saved.disk_gone = false;
+ ctlfs.events.noteLog(p, .save, saved_pane);
+ if (!saved.watch_after_save) return;
saved.watch_after_save = false;
if (filesystem.localPath(saved.path) != null)
p.emit(.{ .watch = .{ .pane = sf.pane, .on = true } });
@@ -5316,12 +5397,14 @@ pub const Pardes = struct {
/// that changes a pane costs a frame, which is why a round trip on a
/// local socket costs microseconds and not a vsync.
pub fn serveFs(p: *Pardes, req: ctlfs.Req) ctlfs.Reply {
- if (!ctlfs.changesPane(req)) return ctlfs.handle(p, req);
+ if (!ctlfs.changesPane(p, req)) return ctlfs.handle(p, req);
p.needs_frame = true;
p.raw_hover_intent = false;
look.cancelLookHover(p);
+ p.fs.serving = true;
const reply = ctlfs.handle(p, req);
- if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NFILE) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
+ p.fs.serving = false;
+ if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NOSPC) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
@@ -5566,6 +5649,8 @@ pub const Pardes = struct {
const asked = p.panes[id] orelse return null;
switch (asked.prompt) {
.del_side => return "k j",
+ // Any path; `-` takes it back.
+ .save => return "<path>",
.repl_choice => {
const ask = p.repl_ask orelse return null;
var w = std.Io.Writer.fixed(buf);
@@ -5579,8 +5664,17 @@ pub const Pardes = struct {
}
}
+ /// Answers the pane's question: a choice it offers, else (null, or a
+ /// key it does not offer) takes it back. The log says which, as
+ /// `answer <serial> <choice|->`, after the `ask` it answers.
pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool {
const asked = p.panes[id] orelse return false;
+ var offered: [64]u8 = undefined;
+ if (p.questionChoices(id, &offered)) |choices| {
+ const chosen: ?u8 = if (choice) |c| (if (std.mem.indexOfScalar(u8, choices, c) != null and c != ' ') c else null) else null;
+ var rec: [32]u8 = undefined;
+ ctlfs.events.notePath(p, "answer", std.fmt.bufPrint(&rec, "{d} {c}", .{ asked.serial, chosen orelse '-' }) catch "");
+ }
switch (asked.prompt) {
.repl_choice => {
asked.prompt = .none;
@@ -5768,6 +5862,8 @@ pub const Pardes = struct {
// A column tag's command acts on that column, empty or not.
p.exec_column = if (header != null) p.header_column else null;
defer p.exec_column = null;
+ p.exec_header = header != null;
+ defer p.exec_header = false;
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
@@ -5870,6 +5966,23 @@ pub const Pardes = struct {
// ---- search (`/`, Find, n/N): results into an output buffer, look the rows ----
+ /// Where pardes started: the directory of what runs from a header.
+ pub fn sessionDir(p: *Pardes) []const u8 {
+ if (p.session_dir_len) |n| return p.session_dir[0..n];
+ if (comptime !hosted) return "/";
+ if (std.c.getcwd(&p.session_dir, p.session_dir.len) == null) return "/";
+ const n: u16 = @intCast(std.mem.indexOfScalar(u8, &p.session_dir, 0) orelse return "/");
+ p.session_dir_len = n;
+ return p.session_dir[0..n];
+ }
+
+ /// The directory a word run from pane `id`'s text or tag runs in, or,
+ /// from a header, the session's.
+ pub fn execDir(p: *Pardes, id: usize) []const u8 {
+ if (p.exec_header) return p.sessionDir();
+ return paneDir(p.panes[id] orelse return p.sessionDir());
+ }
+
pub fn paneDir(pane: *const Pane) []const u8 {
switch (pane.cwd) {
.inherited => |src| return paneDir(src),
@@ -5896,6 +6009,15 @@ pub const Pardes = struct {
p.fs.refused = true;
return;
}
+ // Asked for over 9P (Save from an exec on a terminal): a question
+ // the log says, `ask <serial> save path`, answered on the pane's
+ // ctl with `answer <path>` or `answer -`; asked again, the one
+ // already open stands, not a second on top of it.
+ if (request == .save and p.fs.serving) {
+ if (pane.prompt == .save) return;
+ var rec: [32]u8 = undefined;
+ ctlfs.events.notePath(p, "ask", std.fmt.bufPrint(&rec, "{d} save path", .{pane.serial}) catch "");
+ }
const marker = switch (request) {
.search => |marker| marker,
.save => config.save_marker,
@@ -6384,7 +6506,26 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
+ /// The rows pane `id`'s tag takes at its width, wrapped as `sync` wraps
+ /// it: what a placement must leave it above its body rows.
+ pub fn wantedTagRows(p: *Pardes, id: usize) u16 {
+ const pane = p.panes[id] orelse return BOX_H;
+ if (pane.collapsed or !pane.tag_expanded) return BOX_H;
+ if (p.rects[id].w == 0) return pane.tag_rows; // never laid out
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ const prefix = tagline.prefixIn(wa, pane) catch return pane.tag_rows;
+ const whole = std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) }) catch return pane.tag_rows;
+ const shown = tagline.wrapTag(wa, whole, tagline.tagWidth(p, id)) catch return pane.tag_rows;
+ return @intCast(@max(BOX_H, @min(shown.rows(), MAX_TAG_ROWS)));
+ }
+
pub fn sync(p: *Pardes) void {
+ while (p.unplaced.findFirstSet()) |id| {
+ p.unplaced.unset(id);
+ if (p.panes[id] != null) p.removePane(id, null) catch {};
+ }
tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
@@ -6466,8 +6607,14 @@ pub const Pardes = struct {
pane.tag_top = @intCast(@min(top, lines - pane.tag_rows));
} else pane.tag_top = 0;
const cols = @max(1, r.w -| config.GUTTER);
- const rows = @max(1, r.h -| pane.tag_rows); // the tag steals the top rows
- if ((cols != pane.cols or rows != pane.rows) and r.h > pane.tag_rows) {
+ // The tag steals the top rows. The pty is what the pane shows
+ // (layout keeps a terminal two body rows where the window has
+ // them): a taller pty would put its cursor below the pane.
+ const rows = @max(1, r.h -| pane.tag_rows);
+ // A terminal's pty follows its pane down to a row, whatever the
+ // resize; only a folded one keeps the size it had, and one not
+ // placed yet the grid it was made with.
+ if ((cols != pane.cols or rows != pane.rows) and (r.h > pane.tag_rows or (pane.isTerminal() and !pane.collapsed and r.h > 0))) {
// doc panes have no pty/emulator grid to reflow; just record
// the size so bodyText renders the right number of rows
if (pane.isTerminal()) {
@@ -6518,9 +6665,10 @@ pub const Pardes = struct {
const wait = Messages.messageWake(p, pane) orelse continue;
frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
- // A paste's Enter waiting on its REPL goes at the end of its wait.
+ // A paste's Enter waiting on its REPL goes at the end of its wait,
+ // a time on the clock: the frames from `stepped_ns` that reach it.
for (p.panes) |slot| if (slot) |pane| if (pane.repl_enter > 0) {
- const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns) / animation.frame_ns);
+ const wait = @max(1, (pane.repl_enter_due_ns -| p.stepped_ns + animation.frame_ns - 1) / animation.frame_ns);
frames = if (frames) |soonest| @min(soonest, wait) else wait;
};
return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
@@ -7038,7 +7186,10 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(low, p.active); // focus follows the rows
// Esc takes the question back and the pane stays; so does any other key,
- // and so does a click.
+ // and so does a click. (Top gets rows back first: split at its cursor,
+ // it kept its tag and two, too few to give a pane below its own.)
+ p.panes[top].?.vweight = p.panes[low].?.vweight;
+ p.sync();
p.newScratchBelow(top);
const again = p.active;
p.sync();
@@ -7091,10 +7242,12 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[asked].?.prompt);
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\n", .{serial})));
p.update(.{ .key = .{ .cp = ' ' } });
p.update(.{ .key = .{ .cp = 'd' } });
try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
try std.testing.expect(p.paneBySerial(serial) == null);
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} k\n", .{serial})));
}
// Bare Del from anything but a key closes at once and gives the rows up,
@@ -7874,3 +8027,48 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" {
p.advance(p.stepped_ns + frame);
try std.testing.expectEqual(@as(?u64, null), p.nextWake());
}
+
+test "a pane closed while its question stands ends the question in the log" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "x\n");
+ defer p.deinit();
+ p.settings.placement = .pardes;
+ p.newScratchBelow(0);
+ const closing = p.active;
+ p.newScratchBelow(closing);
+ p.sync();
+ p.active = closing; // a middle pane: Del from a key asks which side
+ const serial = p.panes[closing].?.serial;
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(Pane.Prompt.del_side, p.panes[closing].?.prompt);
+ // A bare answer says what this question takes.
+ try std.testing.expectEqualStrings("answer takes k j or -", th.wr(p, ctlfs.Node.of(serial, .ctl), "answer\n").reply.ename);
+ try p.removePane(closing, null);
+ var want: [64]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} -\ndel {d}", .{ serial, serial })));
+}
+
+test "a Save the host could not do logs no save record" {
+ const th = @import("ninep/testing.zig");
+ const p = try th.withFile(std.testing.allocator, "x\n");
+ defer p.deinit();
+ const Failing = struct {
+ core: *Pardes,
+ const vtable: Host.VTable = .{ .write_file = write };
+ fn write(ctx: ?*anyopaque, pane: u8, path: []const u8, _: []const u8) void {
+ const f: *@This() = @ptrCast(@alignCast(ctx.?));
+ f.core.saveFailed(pane, path, error.AccessDenied);
+ }
+ };
+ var host: Failing = .{ .core = p };
+ p.host = .{ .ctx = &host, .vtable = &Failing.vtable };
+ _ = th.wr(p, ctlfs.Node.of(p.panes[0].?.serial, .body), "more\n");
+ p.perform(.{ .save_file = .{ .pane = 0 } });
+ try std.testing.expect(!th.logHas(p, "\nsave "));
+ try std.testing.expect(p.panes[0].?.file.?.saved_revision != p.panes[0].?.file.?.revision);
+ // With the host's write done, it is logged.
+ p.host = .{};
+ p.perform(.{ .save_file = .{ .pane = 0 } });
+ try std.testing.expect(th.logHas(p, "\nsave "));
+}
diff --git a/src/regexp.zig b/src/regexp.zig
index 97e3dc91..64e1d75a 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -258,3 +258,20 @@ test "a repeat inside a group gives back to what follows the group" {
try std.testing.expectEqual(c.end, m.end);
}
}
+
+test "`$` may end any alternative, not only the last" {
+ const Case = struct { pat: []const u8, text: []const u8, start: usize, end: usize };
+ for ([_]Case{
+ .{ .pat = "h$|zz", .text = "xh", .start = 1, .end = 2 },
+ .{ .pat = "zz|h$", .text = "xh", .start = 1, .end = 2 },
+ .{ .pat = "h$|zz", .text = "hx zz", .start = 3, .end = 5 },
+ .{ .pat = "(h$|zz)", .text = "xh", .start = 1, .end = 2 },
+ }) |c| {
+ var rx = try Regex.compile(c.pat);
+ const m = (try rx.find(c.text, 0, c.text.len, c.text.len)).?;
+ try std.testing.expectEqual(c.start, m.start);
+ try std.testing.expectEqual(c.end, m.end);
+ }
+ var only = try Regex.compile("h$|zz");
+ try std.testing.expectEqual(null, try only.find("hx", 0, 2, 2));
+}
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index 29dec726..cd2998ef 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -16,6 +16,13 @@
const std = @import("std");
const regexp = @import("regexp.zig");
const addr_lang = @import("ninep/addr.zig");
+const modal = @import("modal.zig");
+
+/// The next place after `p` a search may start: a rune on, and past the
+/// text's end once there, which ends a loop over `p <= end`.
+fn stepRune(text: []const u8, p: usize) usize {
+ return if (p >= text.len) p + 1 else modal.nextRune(text, p);
+}
pub const Range = addr_lang.Range;
@@ -68,6 +75,11 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
if (last) |l| l.next = c else first = c;
last = c;
}
+ // An a, c or i text that never met its `.` line is refused, as an
+ // unclosed `{` is: sam takes the end of input for one, but a block that
+ // reaches Edit unfinished was cut short (a write, an open closed), and
+ // half of it inserted is worse than none.
+ if (ps.open_text) return fail(why, "a, c or i text not ended by a . line", .{});
const cmds = first orelse return .{ .ops = &.{}, .dot = dot, .printed = "" };
// acme's u runs at once (ecmd.c:532), outside the edit log; here it is
// an Edit of its own.
@@ -85,6 +97,25 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text
/// block still waiting for its `.` line: a writer of lines (a ctl, exec)
/// hands an Edit the lines after it until it does not.
+/// What an Edit command still waits for: nothing, the `.` line that ends
+/// an a, c or i text block, or the `}` of a group.
+pub const Wait = enum { none, text, group };
+
+/// As `needsMore`, saying which: while a text block is open only a `.`
+/// line can end it, so a caller need not parse the block again for every
+/// line of text in it (ninep/ctl.zig, editEnd), which was quadratic.
+pub fn waitsFor(arena: std.mem.Allocator, command: []const u8) Wait {
+ var why: Why = .{};
+ const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return .none;
+ defer arena.free(src);
+ var ps: Parser = .{ .arena = arena, .src = src, .why = &why };
+ while (true) {
+ const c = ps.parse(0) catch
+ return if (ps.open_text) .text else if (std.mem.eql(u8, why.text(), "unmatched `{'")) .group else .none;
+ if (c == null) return if (ps.open_text) .text else .none;
+ }
+}
+
pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
var why: Why = .{};
const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
@@ -418,9 +449,16 @@ const Exec = struct {
};
}
+ /// A match covers the runes it touches, as an address's does
+ /// (addr.zig): mvzr matches bytes, so `.` would take one byte of `é`
+ /// and x, y and s would split it. From `from` on only, so a match never
+ /// reaches back into what the last one took.
fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match {
if (from > hi) return null;
- return rx.find(ex.text, from, hi, hi) catch fail(ex.why, "{s}", .{addr_lang.e_slow});
+ const m = (rx.find(ex.text, from, hi, hi) catch return fail(ex.why, "{s}", .{addr_lang.e_slow})) orelse return null;
+ var start = modal.runeStart(ex.text, m.start);
+ if (start < from) start = modal.runeEnd(ex.text, m.start);
+ return .{ .start = start, .end = @max(start, modal.runeEnd(ex.text, m.end)) };
}
/// ecmd.c:62, cmdexec: runs `c` from `dot`, answering the dot it leaves.
@@ -516,10 +554,10 @@ const Exec = struct {
const m = (try ex.find(&rx, p1, r.q1)) orelse break;
if (m.start == m.end) {
if (op != null and m.start == op.?) {
- p1 += 1;
+ p1 = stepRune(ex.text, p1);
continue;
}
- p1 = m.end + 1;
+ p1 = stepRune(ex.text, m.end);
} else p1 = m.end;
op = m.end;
n -= 1;
@@ -560,10 +598,10 @@ const Exec = struct {
if (try ex.find(&rx, p, r.q1)) |m| {
if (m.start == m.end) {
if (op != null and m.start == op.?) {
- p += 1;
+ p = stepRune(ex.text, p);
continue;
}
- p = m.end + 1;
+ p = stepRune(ex.text, m.end);
} else p = m.end;
tr = if (xy) .{ .q0 = clip(m.start), .q1 = clip(m.end) } else .{ .q0 = clip(op.?), .q1 = clip(m.start) };
sel_end = m.end;
@@ -723,3 +761,14 @@ test "the dot an Edit leaves selects what a change put where it stood" {
try std.testing.expectEqual(Range{ .q0 = 2, .q1 = 5 }, moveDot(.{ .q0 = 2, .q1 = 2 }, &ops));
try std.testing.expectEqual(Range{ .q0 = 6, .q1 = 7 }, moveDot(.{ .q0 = 5, .q1 = 6 }, &ops));
}
+
+test "x, y and s match whole runes: a byte pattern never splits one" {
+ // é is c3 a9: `.` takes it whole, as it takes a lone combining mark.
+ try expectEdit("\u{e9}", ",x/./a/|/", "\u{e9}|");
+ try expectEdit("e\u{301}t", ",s/./X/g", "XXX");
+ try expectEdit("a\u{e9}b", ",y/\u{e9}/c/-/", "-\u{e9}-");
+ try expectEdit("\u{e9}\u{e9}", ",x/\\xC3/c/E/", "EE");
+ // An invalid byte is a place of its own.
+ try expectEdit("a\x81b", ",x/./a/|/", "a|\x81|b|");
+ try expectEdit("\u{e9}", ",s/$/!/", "\u{e9}!");
+}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index 754b4b96..e7eddec4 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -946,6 +946,7 @@ const Shell = struct {
.pty_signal = ptySignal,
.close_pty = closePty,
.tty_taken = ttyTaken,
+ .fg_name = fgName,
.kill_job = killJob,
.write_file = writeFile,
.write_dump = writeDump,
@@ -1295,6 +1296,12 @@ const Shell = struct {
return host_io.ttyTaken(pt.pid, pt.file.handle);
}
+ fn fgName(ctx: ?*anyopaque, pane: u8, buf: []u8) ?[]const u8 {
+ const s = of(ctx);
+ const pt = s.ptys[pane] orelse return null;
+ return host_io.foregroundName(pt.pid, pt.file.handle, buf);
+ }
+
/// The command panes' exits, told once their output is in (host_io).
fn takeExits(s: *@This()) void {
host_io.takeExits(s.core, &s.ptys, s, closeWatched);
diff --git a/test/fs.py b/test/fs.py
index c5199034..89801a71 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -121,6 +121,10 @@ def look(client, path, source=None):
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)
+ # The scratch it looked from goes: a column holds only so many panes,
+ # each its tag and two rows.
+ if source is None:
+ client.remove(f'/pane/{serial}')
return results[0]
@@ -129,6 +133,16 @@ def execute(client, serial, command):
client.write(f'/pane/{serial}/exec', f'{command}\n'.encode())
+def execute_refused(client, serial, command, words):
+ """A middle click on a builtin that fails: the write fails, saying why."""
+ try:
+ execute(client, serial, command)
+ except OSError as why:
+ assert words in str(why), (command, why)
+ return
+ raise AssertionError(f'{command!r} was taken')
+
+
def walk_tree(client, path='/', skip=('/log', '/os', '/screen')):
"""Stat every entry below `path`, as `find` does, without opening anything."""
seen = []
@@ -148,7 +162,8 @@ def discovery(binary, embedded=False):
top = client.list('/')
# A set: which files the root holds, not the order a listing gives.
assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands',
- 'look', 'exec', 'log', 'screen', 'listeners', 'pane', 'os'}, top
+ 'look', 'exec', 'log', 'screen', 'listeners', 'layout', 'tag', 'tagexec',
+ 'pane', 'col', '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')
@@ -180,7 +195,7 @@ def discovery(binary, embedded=False):
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
+ assert before.split()[1:3] == [b'text', b'0'] and before.rsplit(b' ', 1)[0].endswith(b'/discovery.txt'), before
# /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
@@ -265,9 +280,13 @@ def discovery(binary, embedded=False):
except OSError as refused:
assert why in str(refused), refused
commands = client.read('/commands').decode().splitlines()
- assert 'Newcol root' in commands and 'Save arg pane' in commands, commands
- assert all(row.split()[-1] in ('root', 'pane') for row in commands), commands
- assert client.stat('/commands')['length'] == len('\n'.join(commands)) + 1
+ def row(start):
+ return any(r == start or r.startswith(start + ' ') for r in commands)
+ assert row('Newcol root') and row('Save arg pane') and row('Edit arg both'), commands
+ # word, `arg` if it takes one, its ctl, a setting's values, then `-- ` what it does
+ assert all(r.split()[2 if r.split()[1] == 'arg' else 1] in ('root', 'pane', 'both') for r in commands), commands
+ assert row('Placement arg root acme,pardes'), commands
+ assert client.stat('/commands')['length'] == len('\n'.join(commands).encode()) + 1 # bytes: a description may end in …
client.write(f'/pane/{first}/ctl', b'Msg said through ctl\n')
assert 'said through ctl' in ''.join(cell[0] for cell in client.screen()['cells'])
assert set(client.list('/pane')) == {'new', str(fixture), str(first), str(second)}
@@ -416,12 +435,20 @@ def run_file(binary):
assert run(client, term, b'fi\n') == b'error not run\n' # a syntax error runs nothing
assert run(client, term, b"echo 'open\n") == b'error not run\n' # nor does an open quote
assert run(client, term, b'true\n') == b'exit 0\n' # ...and the shell is clear after
+ # A pty asked for one row gets two: one loses the prompt's input
+ # mark, and the shell would read busy for ever.
+ client.write(f'/pane/{term}/pty/ctl', b'winsize 20 1\n')
+ time.sleep(.5)
+ assert run(client, term, b'true\n') == b'exit 0\n'
+ assert client.read(f'/pane/{term}/pty/ctl').split()[2] == b'2', client.read(f'/pane/{term}/pty/ctl')
+ client.write(f'/pane/{term}/pty/ctl', b'winsize 80 24\n')
with Client(address) as other:
slow = []
waiter = threading.Thread(target=lambda: slow.append(run(other, term, b'sleep 1\n')), daemon=True)
waiter.start()
time.sleep(.3)
- assert run(client, term, b'true\n') == b'busy\n'
+ # The job the other client ran holds the tty, and says so.
+ assert run(client, term, b'true\n') == b'busy: sleep is running\n'
waiter.join(5)
assert slow == [b'exit 0\n'], slow
closing = []
@@ -508,6 +535,26 @@ def python_repl_runs_one_line(binary):
record = b'MX0 0 0 10 print(6*7)\n'
client.rpc(118, struct.pack('<IQI', fid, 0, len(record)) + record)
assert wait_for('\n42'), body()
+ # After the session has sat idle, the Enter still waits for
+ # the paste: core time is as old as the last frame then.
+ for n in range(5):
+ time.sleep(2)
+ line = f'print({1000 + n})'.encode()
+ record = b'MX0 0 0 %d %s\n' % (len(line), line)
+ client.rpc(118, struct.pack('<IQI', fid, 0, len(record)) + record)
+ assert wait_for(f'\n{1000 + n}'), (n, body())
+ # More than one line, with and without a newline at the end,
+ # and a block with a statement after it: each runs whole,
+ # and the REPL is back at its prompt, not at `...`.
+ for code, outputs in [(b'print(11)\nprint(22)', ['11', '22']),
+ (b'print(33)\nprint(44)\n', ['33', '44']),
+ (b'for i in range(2):\n print(55 + i)\nprint(77)\n', ['55', '56', '77'])]:
+ record = b'MX0 0 0 %d %s\n' % (len(code), code)
+ client.rpc(118, struct.pack('<IQI', fid, 0, len(record)) + record)
+ for out in outputs:
+ assert wait_for(f'\n{out}\n'), (code, out, body())
+ time.sleep(.5)
+ assert body().rstrip().endswith('>>>'), (code, body()[-200:])
finally:
client.close(fid)
@@ -528,8 +575,38 @@ def signal_cleans_up(binary):
assert not address.exists() and not detached.exists(), (sig, list(root.iterdir()))
+def cut_writes(binary):
+ """A write bigger than a 9P message comes as several, cut anywhere: each
+ command line runs once, whole, an Edit block once it ends, and a last
+ line with no newline when the open closes. Never a fragment."""
+ with tempfile.TemporaryDirectory(prefix='pardes-cut-') as directory:
+ with session(binary, Path(directory), 'cut') as (client, _):
+ burst = b''.join(b'Msg burst-%04d\n' % i for i in range(1000))
+ assert len(burst) > client.msize
+ client.write('/exec', burst)
+ log = client.read('/log').decode()
+ said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'burst-' in line]
+ assert said == ['burst-%04d' % i for i in range(1000)], said[:3] + said[-3:]
+ assert '\nrun ' not in log and 'exit 127' not in log, log[-400:]
+ client.write('/exec', b'Msg no-newline-at-end')
+ assert 'msg ' in client.read('/log').decode().split('no-newline-at-end')[0][-40:]
+ serial = new_pane(client, b'')
+ block = b'Edit ,a\n' + b''.join(b'line %05d of the block\n' % i for i in range(2500)) + b'.\n'
+ assert len(block) > 50000
+ client.write(f'/pane/{serial}/ctl', block)
+ body = client.read(f'/pane/{serial}/body').decode().splitlines()
+ assert body == ['line %05d of the block' % i for i in range(2500)], (len(body), body[:2])
+ # A detached session with no frontend is 160x50 until told.
+ screen = client.screen()
+ assert (screen['cols'], screen['rows']) == (160, 50), (screen['cols'], screen['rows'])
+ client.write('/ctl', b'size 132 40\n')
+ screen = client.screen()
+ assert (screen['cols'], screen['rows']) == (132, 40), (screen['cols'], screen['rows'])
+
+
def test(binary, quic=False):
started = time.monotonic()
+ cut_writes(binary)
run_file(binary)
exit_answers(binary)
unattended_messages_expire(binary)
@@ -665,8 +742,9 @@ def test(binary, quic=False):
execute(remote, control, 'Mount peer ' + str(address))
opened = look(remote, '/n/peer/pane/1/body', source=control)
assert remote.read(f'/pane/{opened}/body') == client.read('/pane/1/body')
- execute(remote, control, 'Mount peer ' + str(own_address))
- execute(remote, control, 'Unmount peer')
+ # A builtin that fails through exec fails the write (one rule).
+ execute_refused(remote, control, 'Mount peer ' + str(own_address), 'already mounted')
+ execute_refused(remote, control, 'Unmount peer', 'mount in use') # a pane is open on it
remote.write(f'/pane/{opened}/body', b'runtime mounted Save\n', truncate=True)
remote.write(f'/pane/{opened}/exec', b'Save\n')
assert client.read('/pane/1/body') == b'runtime mounted Save\n'
@@ -693,7 +771,7 @@ def test(binary, quic=False):
with session(binary, root, name, tty=tty) as (old, address):
old.write('/pane/1/body', b'dumped state\n', truncate=True)
control = new_pane(old, b'')
- execute(old, control, 'Restore ' + str(root / 'missing dump.zon'))
+ execute_refused(old, control, 'Restore ' + str(root / 'missing dump.zon'), 'no such file')
assert old.read('/pane/1/body') == b'dumped state\n'
execute(old, control, 'Mount own ' + str(address))
execute(old, control, 'Dump')
@@ -735,7 +813,7 @@ def test(binary, quic=False):
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 exec, not the body'
- assert restored.screen()['cols'] == 80
+ assert restored.screen()['cols'] == (80 if tty else 160) # a detached one is 160x50
opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id)
assert restored.read(f'/pane/{opened}/body') == b'initial\n'
@@ -909,6 +987,7 @@ def test(binary, quic=False):
assert path.read_bytes() == b'written through long filename\n'
opened = look(client, '/n/os' + str(path))
assert client.read(f'/pane/{opened}/body') == path.read_bytes()
+ client.remove(f'/pane/{opened}') # a long name's tag takes rows
opened = look(client, '/n/os' + str(directory))
listing = client.read(f'/pane/{opened}/body')
for path in directory.iterdir():
diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig
index 2f78ca1a..bd620c2d 100644
--- a/test/fs_namespace.zig
+++ b/test/fs_namespace.zig
@@ -6,9 +6,15 @@ test "runtime Mount and Unmount copy command arguments and change Look resolutio
const gpa = std.testing.allocator;
const p = try pardes.Pardes.init(gpa, .{ .tty_only = true });
defer p.deinit();
- var command = "Mount peer /tmp/a socket.sock".*;
- try std.testing.expect(p.executeBuiltinLine(0, &command));
- @memset(&command, 'x');
+ // No peer answers at the dial: Mount mounts nothing (it dials first).
+ try std.testing.expect(p.executeBuiltinLine(0, "Mount peer /tmp/a socket.sock"));
+ try std.testing.expectEqual(@as(usize, 0), p.fs.mounts.items.len);
+ // Mounted (as one that answered is), its arguments are copies.
+ var name = "peer".*;
+ var dial = "/tmp/a socket.sock".*;
+ try p.fs.mount(gpa, &name, &dial);
+ @memset(&name, 'x');
+ @memset(&dial, 'x');
try std.testing.expectEqual(@as(usize, 1), p.fs.mounts.items.len);
try std.testing.expectEqualStrings("peer", p.fs.mounts.items[0].name);
try std.testing.expectEqualStrings("/tmp/a socket.sock", p.fs.mounts.items[0].dial);
diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt
index 263f1bcc..01bdfc7c 100644
--- a/test/gui-goldens.txt
+++ b/test/gui-goldens.txt
@@ -6,12 +6,12 @@
06-tag-focus-selection 94aff8499a6e6f399761ffdd56369d526fbe65232eb21c88addd12973af611aa
07-prompt-notice 041603b841605b0a9456cbbb9b3e06e219b0a08a58e2d38229e1c81366577a87
08-wide-glyph-edges 94b0beb7cc2f2a09259f4f7d57999001ef75ed53ea210a65a1510404bd161af9
-09-collapsed-focus-tint 83db5e1acb69424c8ecdb247fcf359ff6b010a529a628012991d74b4a6428b89
-10-tagbottom 3a7d5bc8b2e821ffb082ccd548c6f312947f614be80324fbd79b4182e34cf387
-12-multiline-tag 93cdcfa151dc823d3d13af270157dcca8f69ecfd0ace7e0b888c088ca8c3deaf
+09-collapsed-focus-tint 1d0e6e4b371bc3b2bba96d9177b91f240fa4bef38c62470dc078fa9feed119ac
+10-tagbottom f7175dfb338b75c5ac3457c8422dc0e352c46d51b2c52533ce32ef143fcf6c6b
+12-multiline-tag ddd7d775a246e91b88f0eff98b6f9bea8aa5d63dbfbe931bd830ccb9086bd5c4
13-image f609043fc0fcdb55369b836b61fcbc631f786cc2295a5ddd72c0d9a59b177a60
14-theme-ink 881b7e99f38ad4aa3c46d2c61e20598010175d7858712a67fa6638fc29f12e14
15-theme-acme-light ca5252f5117ac8d6c679cef917c9f316c9ca311fc7a8dcbac2796b4fc04d3b64
16-debug 591bb7c45b4b9c46cd2458330b4fa3962afc6dac827fa6f5e20e927a72c586e6
17-terminal 38299427891e31de0b5597159501c161474620eb26fd7708a6396f0fd685209e
-18-mid-transition ab31f5fde35367edda6c7f4e45bf4396596f242a60938f384df6c392bd3712d2
+18-mid-transition 80cf350f8736fa4b348225a3872572a799ce57c763939796b68e8c839418f8ee
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
index 5f172f9d..2b0eedad 100644
--- a/test/macos_e2e.swift
+++ b/test/macos_e2e.swift
@@ -1251,7 +1251,7 @@ private func buildWorld(stem: String) throws -> String {
// sources $HOME/.bashrc (host_io.Shell) — so this is what pins the
// prompt to `$ ` and keeps the developer's history out of the capture.
try "PS1='$ '\nHISTFILE=\n".write(toFile: "\(home)/.bashrc", atomically: true, encoding: .utf8)
- // ...and the config is not empty, because the DEFAULT shell is fish, whose
+ // ...and the config is not empty, because the DEFAULT shell is $SHELL (fish, say), whose
// prompt carries a hostname and whose greeting carries a version. A golden
// taken against that is a golden for one machine.
try "Shell bash\n".write(toFile: "\(pardesConfig)/init", atomically: true, encoding: .utf8)
diff --git a/test/output.zig b/test/output.zig
index 95cdbfb7..478f1b4a 100644
--- a/test/output.zig
+++ b/test/output.zig
@@ -544,10 +544,11 @@ test "Config prints the startup path and refreshes its one output" {
for ([_][]const u8{
"Startup config: " ++ path ++ "\n",
"Theme orchard\n",
- "Shell requested (new panes) " ++ config.default_shell ++ " (default)\n",
"Shell effective (last spawn) (none)\n",
"Shell pending on\n",
}) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null);
+ var default_line: [256]u8 = undefined;
+ try std.testing.expect(std.mem.indexOf(u8, out.content, try std.fmt.bufPrint(&default_line, "Shell requested (new panes) {s} (default)\n", .{config.defaultShell()})) != null);
try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path));
try std.testing.expectEqual(panes.Output.Origin{ .cmd = .Config }, out.output.?.from);
@@ -861,6 +862,7 @@ test "n/N resumes the result output whose Look moved focus away" {
const p = try Pardes.init(std.testing.allocator, .{ .file = path, .cols = 80, .rows = 24 });
defer p.deinit();
p.locations_config.layout = .@"inline";
+ p.settings.look_word = .list; // the +Search listing is what is walked
try std.testing.expect(p.executeBuiltinLine(0, "Look target"));
const rid = p.panes[0].?.search_pane orelse return error.MissingResults;
@@ -1590,10 +1592,13 @@ test "stacked search and LSP results retain source tint navigation and dump owne
}
restored.active = id;
pardes.look.armLookWalk(restored, id);
- // The dot came back on the match it was on; the walk goes on.
+ // The dot came back on the match it was on; the walk goes on
+ // from it both ways, N wrapping to the last as it does live.
try std.testing.expectEqual(@as(i32, @intCast(matches[0])), restored.panes[id].?.body.cur_row);
- restored.update(.{ .key = .{ .cp = 'n' } });
+ restored.update(.{ .key = .{ .cp = 'N' } });
try std.testing.expectEqual(@as(i32, @intCast(matches[1])), restored.panes[id].?.body.cur_row);
+ restored.update(.{ .key = .{ .cp = 'n' } });
+ try std.testing.expectEqual(@as(i32, @intCast(matches[0])), restored.panes[id].?.body.cur_row);
try std.testing.expectEqualStrings(source_text, source.file.?.content);
}
}
diff --git a/test/panes.zig b/test/panes.zig
index 25f0f6db..3d2821aa 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -4853,7 +4853,7 @@ const LookResetTests = struct {
try std.testing.expectEqualStrings("needle", selected(pane));
}
- test "Look clears both source and existing destination selections before focus" {
+ test "Look clears the source's click highlight, not its dot, and the destination's selections before focus" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
const source = try p.setTestFile("source\nlines\n");
@@ -4872,9 +4872,12 @@ const LookResetTests = struct {
try seed(destination, "previous destination");
pardes.look.lookAt(p, 0, source.pointer_selections[0].?.raw_text.?);
try std.testing.expectEqual(id, p.active);
- try cleared(source);
+ // The source keeps its dot (acme's look leaves it); the click's
+ // highlight goes.
+ for (source.sel) |selection| try std.testing.expectEqual(.none, selection.state);
+ for (source.pointer_selections) |selection| try std.testing.expect(selection == null);
+ try std.testing.expect(source.body.vsel.active);
try cleared(destination);
- try std.testing.expect(!source.body.vsel.active);
if (range) {
try std.testing.expectEqual(@as(i32, 1), destination.body.cur_row);
try std.testing.expectEqualStrings("rav", selected(destination));
@@ -4890,3 +4893,47 @@ const LookResetTests = struct {
test {
_ = LookResetTests;
}
+
+test "a terminal keeps its tag and two body rows through a squeeze, a drag and a smaller window; a text pane does not" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ p.presentation.enabled = false;
+ p.sync();
+ while (p.nextEffect()) |_| {}
+ const term = for (p.panes, 0..) |slot, i| {
+ if (slot) |pane| if (pane.isTerminal()) break i;
+ } else return error.NoTerminal;
+ p.newScratchBelow(term);
+ p.sync();
+ while (p.nextEffect()) |_| {}
+ const col = layout.findPane(p, term).?.col;
+ const floor = p.panes[term].?.tag_rows + layout.min_body_rows;
+ // Squeezed by the weights: the others give it its floor.
+ p.panes[term].?.vweight = 0.01;
+ for (p.col_panes[col][0..p.col_n[col]]) |k| if (k != term) {
+ p.panes[k].?.vweight = 1000;
+ };
+ p.sync();
+ try std.testing.expectEqual(floor, p.rects[term].h);
+ var rows: ?u16 = null;
+ while (p.nextEffect()) |effect| if (effect == .resize_pty and effect.resize_pty.pane == term) {
+ rows = effect.resize_pty.rows;
+ };
+ try std.testing.expectEqual(@as(?u16, layout.min_body_rows), rows);
+ // A drag of the grip under it up to its tag stops at the floor; one
+ // the other way takes a text pane down to its tag alone.
+ const k = layout.findPane(p, term).?.idx;
+ if (k + 1 < p.col_n[col]) {
+ layout.applyRowSplit(p, col, k, p.rects[term].y);
+ p.sync();
+ try std.testing.expectEqual(floor, p.rects[term].h);
+ const text = p.col_panes[col][k + 1];
+ layout.applyRowSplit(p, col, k, p.screen_h);
+ p.sync();
+ try std.testing.expect(p.rects[text].h <= p.panes[text].?.tag_rows);
+ }
+ // A window too short for all: the terminal keeps its floor still.
+ p.update(.{ .resize = .{ .cols = 100, .rows = 10 } });
+ p.sync();
+ try std.testing.expect(p.rects[term].h >= p.panes[term].?.tag_rows + layout.min_body_rows);
+}
diff --git a/test/selfmount.py b/test/selfmount.py
index 90960bed..55809740 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -105,6 +105,39 @@ def run(binary):
check(client.read(f'/pane/{first}/body') == b'HELLO initial\n', f'Save wrote back through the mount into pane {first}')
text = ''.join(cell[0] for cell in client.screen()['cells'])
check('saved ' + mount in text, 'the editor reported the save')
+ # Through the kernel mount a write is cut at its msize: a
+ # 1000-line burst and a 50 KB Edit block each still run whole,
+ # line by line, and no piece of a line runs as a shell command.
+ # sh: bash's and fish's prompt files are not reachable in 9ns's
+ # namespace here, and a command pane needs no prompt marks.
+ client.write('/ctl', b'Shell sh\n')
+ # From the fixture's directory: a pane on the mount has one a
+ # shell cannot enter.
+ client.write('/focus', f'{first}\n'.encode())
+
+ def run(line):
+ """A command pane runs the line; answers its exit status."""
+ seen = client.read('/log').decode().count('\nexit ')
+ client.write('/exec', line)
+ deadline = time.monotonic() + 20
+ while time.monotonic() < deadline:
+ drain()
+ exits = [row for row in client.read('/log').decode().splitlines() if row.startswith('exit ')]
+ if len(exits) > seen:
+ return exits[-1].split()[2]
+ time.sleep(.1)
+ return 'timeout'
+ got = run(f"seq -f 'Msg mnt-%04g' 0 999 > {mount}/exec\n".encode())
+ check(got == '0', f'a 1000-line burst written through the mount ({got!r})')
+ log = client.read('/log').decode()
+ said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'mnt-' in line]
+ check(said == ['mnt-%04d' % i for i in range(1000)], f'each line of it ran once, whole ({len(said)} said)')
+ check('exit 127' not in log, 'no piece of a line ran as a command')
+ scratch = int(client.read('/pane/new'))
+ maker = "import sys; sys.stdout.write('Edit ,a\\n' + ''.join('line %05d of the block\\n' % i for i in range(2500)) + '.\\n')"
+ check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount')
+ body = client.read(f'/pane/{scratch}/body').decode().splitlines()
+ check(body == ['line %05d of the block' % i for i in range(2500)], f'it ran once, whole ({len(body)} lines)')
client.write('/exec', b'Msg still here\n')
text = ''.join(cell[0] for cell in client.screen()['cells'])
check('still here' in text, 'the editor answers after all of that')
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 2fa8e89c..c49e7645 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -562,8 +562,9 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// XDG directory that points outside this per-snapshot fake home.
const config_home = try std.fmt.allocPrintSentinel(arena, "{s}/.config", .{home}, 0);
_ = setenv("XDG_CONFIG_HOME", config_home, 1);
- // ...and it is not empty: the DEFAULT shell is fish, whose prompt carries
- // a hostname and whose greeting carries a version, so a golden taken
+ // ...and it is not empty: the DEFAULT shell is $SHELL, whatever this
+ // machine's user logs in with (fish's prompt carries a hostname and its
+ // greeting a version), so a golden taken
// against it would be a golden for this machine. Every script here runs
// bash — the .bashrc above pins its prompt to `$ ` — and the fish path is
// covered by host_io.Shell's unit tests instead. This is an ordinary
diff --git a/test/snapshots/argv.golden b/test/snapshots/argv.golden
index ab143a88..e4d41d6c 100644
--- a/test/snapshots/argv.golden
+++ b/test/snapshots/argv.golden
@@ -20,7 +20,7 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/argv/cwd/+New Save Tty Collapse Del
| 1
== snap filearg grid=100x31 cursor=7,3
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
diff --git a/test/snapshots/cornerdrag.golden b/test/snapshots/cornerdrag.golden
index 730cc2bf..30c1830a 100644
--- a/test/snapshots/cornerdrag.golden
+++ b/test/snapshots/cornerdrag.golden
@@ -243,7 +243,7 @@
|24:
|25:
|26:
-|27:
-|28:
-|29:
-|30: /tmp/pardes-snap/cornerdrag/cwd Tty+bash Save Mode Filter
+|27: /tmp/pardes-snap/cornerdrag/cwd Tty+bash Save Mode Filter
+|28: Collapse Del
+|29: w.txt
+|30:
diff --git a/test/snapshots/filewatch.golden b/test/snapshots/filewatch.golden
index f19168b1..589cfc3a 100644
--- a/test/snapshots/filewatch.golden
+++ b/test/snapshots/filewatch.golden
@@ -28,20 +28,8 @@
| 7 line 7
| 8 line 8
| 9
-== snap reloaded grid=210x31 cursor=15,7
-|6: /tmp/pardes-snap/filewatch/cwd/w.txt Save Tty Collapse Del
-|7: 1 EXTERNAL --:--:-- reloaded /tmp/pardes-snap/filewatch/cwd/w.txt
-|8: 2
-|9:
-|10:
-|11:
-|12:
-|13:
-|14:
-|15:
-|16:
-|17:
-|18:
+== snap kept grid=210x31 cursor=15,7
+|7: 1 liMYEDITne 1 /tmp/pardes-snap/filewatch/cwd/w.txt changed on disk (get reloads it, Save overwrites it)
== snap reloaded_sub grid=210x31 cursor=15,7
|20: 1 RENAMED --:--:-- reloaded /tmp/pardes-snap/filewatch/cwd/sub/s.txt
|21: 2
@@ -52,18 +40,3 @@
|26:
|27:
|28:
-== snap undone grid=210x31 cursor=15,7
-|6: * /tmp/pardes-snap/filewatch/cwd/w.txt Save Tty Collapse Del
-|7: 1 liMYEDITne 1
-|8: 2 line 2
-|9: 3 /tmp/pardes-snap/filewatch/cwd/sub/s.txt
-|10: 4 line 4
-|11: 5 line 5
-|12: 6 line 6
-|13: 7 line 7
-|14: 8 line 8
-|15: 9 line 9
-|16: 10 line 10
-|17: 11 line 11
-|18: 12 line 12
-|20: 1 RENAMED
diff --git a/test/snapshots/filewatch.snap b/test/snapshots/filewatch.snap
index 9e6e6a13..6664519f 100644
--- a/test/snapshots/filewatch.snap
+++ b/test/snapshots/filewatch.snap
@@ -1,6 +1,6 @@
-# external file updates: a write from outside pardes lands in the open pane,
-# and Undo brings the unsaved edit back. That is the whole contract — the
-# update is committed like any other edit, so nothing has to be merged.
+# external file updates: a write from outside pardes lands in an open pane
+# that has no unsaved edits, committed like any other edit; a pane WITH
+# unsaved edits keeps them and says the file changed on disk (File.changed).
#
# The writer must be a STRANGER to the editor. This script used to have a
# pardes PANE run the write, which proved much less than it looked: that
@@ -54,18 +54,13 @@ settle 100
stable 400 5000
snap edited
# ---- from here to `snap reloaded_sub`, pardes is sent NOTHING ----
+# w.txt has the unsaved edit: it is kept, and the change is said
run printf 'EXTERNAL\n' > w.txt
-wait 10000 EXTERNAL
+wait 10000 changed on disk
stable 700 10000
-snap reloaded
+snap kept
+# sub/s.txt has none: it takes the new text
run printf 'RENAMED\n' > sub/t && mv sub/t sub/s.txt
wait 10000 RENAMED
stable 700 10000
snap reloaded_sub
-# back in the file pane: one undo and the unsaved edit is there again
-press left 10 8
-release left 10 8
-stable 400 5000
-key u
-stable 400 5000
-snap undone
diff --git a/test/snapshots/jumps.golden b/test/snapshots/jumps.golden
index 310f00f9..0a2063b0 100644
--- a/test/snapshots/jumps.golden
+++ b/test/snapshots/jumps.golden
@@ -134,9 +134,9 @@
== snap jumplist grid=150x31 cursor=7,17
|3: 86 line 86 Jumplist ls
|16: /tmp/pardes-snap/jumps/cwd/+Jumps Save Tty Collapse Del LocationsConfig
-|17: 1 @p1:3:3 /tmp/pardes-snap/jumps/cwd
+|17: 1 @p2:3:3 /tmp/pardes-snap/jumps/cwd
|18: 2 /tmp/pardes-snap/jumps/cwd/big.txt:1:1 line 1
-|19: 3 @p1:3:3 /tmp/pardes-snap/jumps/cwd
+|19: 3 @p2:3:3 /tmp/pardes-snap/jumps/cwd
|20: 4 /tmp/pardes-snap/jumps/cwd/big.txt:100:1 line 100
|21: 5
|22:
diff --git a/test/snapshots/lookpanes.golden b/test/snapshots/lookpanes.golden
index a9088166..fbab6eb1 100644
--- a/test/snapshots/lookpanes.golden
+++ b/test/snapshots/lookpanes.golden
@@ -14,18 +14,18 @@
|6: /tmp/pardes-snap/lookpanes/cwd/alpha/zqonly.txt Save Tty Colla
|7: pse Del /tmp/pardes-snap/lookpanes/cwd/alpha Tty+bash Save Mode Filter
|8: 1 ONLY-IN-ALPHA Collapse Del
-== snap clicked-wins grid=130x51 cursor=7,11
-|9: /tmp/pardes-snap/lookpanes/cwd/beta/both.txt Save Tty Collapse ls
-|10: Del amark.txt both.txt pick.txt twin.txt zqonly.txt
-|11: 1 BOTH-FROM-BETA
+== snap clicked-wins grid=130x51 cursor=7,12
+|10: /tmp/pardes-snap/lookpanes/cwd/beta/both.txt Save Tty Collapse amark.txt both.txt pick.txt twin.txt zqonly.txt
+|11: Del
+|12: 1 BOTH-FROM-BETA
== snap mru-alpha grid=130x51 cursor=72,14
-|12: /tmp/pardes-snap/lookpanes/cwd/alpha/pick.txt Save Tty Collaps
+|12: 1 BOTH-FROM-BETA /tmp/pardes-snap/lookpanes/cwd/alpha/pick.txt Save Tty Collaps
|13: e Del
|14: 1 PICK-FROM-ALPHA
-== snap mru-beta grid=130x51 cursor=7,14
-|12: /tmp/pardes-snap/lookpanes/cwd/beta/twin.txt Save Tty Collapse /tmp/pardes-snap/lookpanes/cwd/alpha/pick.txt Save Tty Collaps
-|13: Del e Del
-|14: 1 TWIN-FROM-BETA 1 PICK-FROM-ALPHA
+== snap mru-beta grid=130x51 cursor=7,16
+|14: /tmp/pardes-snap/lookpanes/cwd/beta/twin.txt Save Tty Collapse 1 PICK-FROM-ALPHA
+|15: Del
+|16: 1 TWIN-FROM-BETA
== style mru-beta grid=130x51
|0: 0-70 #b2c3b8,#1d2824, 71-129 d,#1d2824,
|1: 0-1 #ffffff,#887455, 2-2 d,#314738, 3-34 #becfc4,#314738, 35-64 d,#314738, 65-66 #ffffff,#887455, 67-67 d,#1d2824, 68-99 #b2c3b8,#1d2824, 100-129 d,#1d2824,
@@ -36,14 +36,14 @@
|6: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-39 #b2c3b8,#1d2824, 40-49 #d3b0d9,#1d2824, 50-64 #b2c3b8,#1d2824, 65-65 d,#608269, 66-129 d,#0d1410,
|7: 0-2 d,#1d2824, 3-9 #b2c3b8,#1d2824, 10-64 d,#1d2824, 65-66 #ffffff,#496656, 67-67 d,#1d2824, 68-104 #b2c3b8,#1d2824, 105-112 #d3b0d9,#1d2824, 113-129 #b2c3b8,#1d2824,
|8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-19 #e5eee5,#0d1410, 20-64 d,#0d1410, 65-67 d,#1d2824, 68-80 #b2c3b8,#1d2824, 81-129 d,#1d2824,
-|9: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-46 #d3b0d9,#1d2824, 47-64 #b2c3b8,#1d2824, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
-|10: 0-2 d,#1d2824, 3-6 #b2c3b8,#1d2824, 7-64 d,#1d2824, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
-|11: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-65 d,#608269, 66-129 d,#0d1410,
-|12: 0-1 #000000,#add38a, 2-2 d,#314738, 3-38 #becfc4,#314738, 39-46 #dfbce5,#314738, 47-64 #becfc4,#314738, 65-66 #ffffff,#496656, 67-67 d,#1d2824, 68-104 #b2c3b8,#1d2824, 105-112 #d3b0d9,#1d2824, 113-129 #b2c3b8,#1d2824,
-|13: 0-2 d,#314738, 3-6 #becfc4,#314738, 7-64 d,#314738, 65-67 d,#1d2824, 68-72 #b2c3b8,#1d2824, 73-129 d,#1d2824,
-|14: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-86 #e5eee5,#0d1410, 87-129 d,#0d1410,
-|15: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
-|16: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
+|9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
+|10: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-46 #d3b0d9,#1d2824, 47-64 #b2c3b8,#1d2824, 65-65 d,#608269, 66-66 d,#0d1410, 67-129 #e5eee5,#0d1410,
+|11: 0-2 d,#1d2824, 3-6 #b2c3b8,#1d2824, 7-64 d,#1d2824, 65-65 d,#608269, 66-129 d,#0d1410,
+|12: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-66 #ffffff,#496656, 67-67 d,#1d2824, 68-104 #b2c3b8,#1d2824, 105-112 #d3b0d9,#1d2824, 113-129 #b2c3b8,#1d2824,
+|13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-67 d,#1d2824, 68-72 #b2c3b8,#1d2824, 73-129 d,#1d2824,
+|14: 0-1 #000000,#add38a, 2-2 d,#314738, 3-38 #becfc4,#314738, 39-46 #dfbce5,#314738, 47-64 #becfc4,#314738, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-86 #e5eee5,#0d1410, 87-129 d,#0d1410,
+|15: 0-2 d,#314738, 3-6 #becfc4,#314738, 7-64 d,#314738, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
+|16: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-20 #e5eee5,#0d1410, 21-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
|17: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-64 d,#0d1410, 65-65 d,#608269, 66-66 d,#0d1410, 67-71 #59675c,#0d1410, 72-129 d,#0d1410,
diff --git a/test/snapshots/nested.golden b/test/snapshots/nested.golden
index e5244e71..9758c491 100644
--- a/test/snapshots/nested.golden
+++ b/test/snapshots/nested.golden
@@ -20,7 +20,7 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/nested/cwd/+New Save Tty Collapse Del
| 1
== snap no-arg grid=100x31 cursor=4,7
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
@@ -44,7 +44,7 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/nested/cwd/+New Save Tty Collapse Del
| 1
== snap dir-arg grid=100x31 cursor=4,8
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
@@ -68,5 +68,5 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/nested/cwd/+New Save Tty Collapse Del
| 1
diff --git a/test/snapshots/psearch.golden b/test/snapshots/psearch.golden
index ee65ede2..20523962 100644
--- a/test/snapshots/psearch.golden
+++ b/test/snapshots/psearch.golden
@@ -20,18 +20,18 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/psearch/cwd/+New Save Tty Collapse Del
| 1
== snap typing grid=140x31 cursor=139,3
|3: printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n' /MARK
== snap results grid=140x31 cursor=7,4
|3: printf 'x MA''RK a\nzz\nx MA''RK b\nzz\nx MA''RK c\n'
|10: /tmp/pardes-snap/psearch/cwd/+Search Save Tty Collapse Del LocationsConfig
-|11: 1 @p0:2:3-6
+|11: 1 @p1:2:3-6
|12: 2 x MARK a
-|13: 3 @p0:4:3-6
+|13: 3 @p1:4:3-6
|14: 4 x MARK b
-|15: 5 @p0:6:3-6
+|15: 5 @p1:6:3-6
|16: 6 x MARK c
|17: 7
== style results grid=140x31
@@ -56,7 +56,7 @@
|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-139 d,#0d1410,
|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-139 d,#0d1410,
|20: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-139 d,#0d1410,
-|21: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-3 #b2c3b8,#1d2824, 4-7 #d3b0d9,#1d2824, 8-29 #b2c3b8,#1d2824, 30-139 d,#1d2824,
+|21: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-31 #b2c3b8,#1d2824, 32-35 #d3b0d9,#1d2824, 36-57 #b2c3b8,#1d2824, 58-139 d,#1d2824,
|22: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-139 d,#0d1410,
|23: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-139 d,#0d1410,
|24: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-139 d,#0d1410,
diff --git a/test/snapshots/psearch.snap b/test/snapshots/psearch.snap
index a46e2b9d..6e463119 100644
--- a/test/snapshots/psearch.snap
+++ b/test/snapshots/psearch.snap
@@ -1,7 +1,7 @@
# `/` search on a TERMINAL pane: the same tag input as a file pane, searching
# the shell's own scrollback (the plain text view Look reads, prompt rows
# hidden). Results land in a +Search OUTPUT BUFFER — a file pane with no file
-# behind it, so its tag has no Save — and point at `@p0:LINE:COL-ENDCOL`, the
+# behind it, so its tag has no Save — and point at `@p1:LINE:COL-ENDCOL` (the terminal's serial), the
# look target that names a live pane, since a terminal has no path. n/N SELECT
# the next/prev row in the buffer and open nothing; Enter — the look chord —
# is what scrolls the terminal to that line of its scrollback and selects the
@@ -31,12 +31,12 @@ stable 400 5000
text MARK
stable 400 5000
snap typing
-# Enter opens the results buffer — three @p0:LINE:COL rows — and goes to the
+# Enter opens the results buffer — three @p1:LINE:COL rows — and goes to the
# first of them: the terminal's cursor lands on that scrollback line, on the
# matched SPAN (cols 3..6, past the leading "x ") — not the start of the line,
# and selected rather than merely parked on. No `n` was pressed to get here.
key enter
-wait 10000 @p0:2:3
+wait 10000 @p1:2:3
stable 700 15000
snap results
snapstyle results
@@ -83,6 +83,6 @@ key c-b
stable 600 8000
press right 5 5
release right 5 5
-wait 10000 @p0:2:3
+wait 10000 @p1:2:3
stable 700 15000
snap rightsearch
diff --git a/test/snapshots/reflow.golden b/test/snapshots/reflow.golden
index ab8bfe9b..afd4b93f 100644
--- a/test/snapshots/reflow.golden
+++ b/test/snapshots/reflow.golden
@@ -16,5 +16,5 @@
| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyydone
| $
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/reflow/cwd/+New Save Tty Collapse Del
| 1
diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap
index 8c81db1d..75d0212c 100644
--- a/test/snapshots/rsearch.snap
+++ b/test/snapshots/rsearch.snap
@@ -11,6 +11,9 @@ file code.txt run count_x now\nopen sub/dep.txt:3 here\ncount_x again\npick two
# the top of the body -- which moves every row this script clicks. What the
# verbs do is what is under test, not the announcement.
config Verbose off
+# the +Search listing is what this walks: LookWord list (search, acme's look3,
+# is the default; ctl.zig tests it)
+config LookWord list
start 31 210 -n 1
wait 8000 code.txt
stable 700 20000
diff --git a/test/snapshots/shellset.golden b/test/snapshots/shellset.golden
index 0af8f2e1..e2830801 100644
--- a/test/snapshots/shellset.golden
+++ b/test/snapshots/shellset.golden
@@ -4,7 +4,7 @@
| /tmp/pardes-snap/shellset/cwd/cmd.txt Save Tty Collapse Del
| 1 Shell zznosuchshell
== snap builtin-ran grid=90x21 cursor=25,3
-|3: 1 Shell zznosuchshell Shell
+|3: 1 Shel Shell: no shell "zznosuchshell" (a name on the usual paths, or a path to one)
== snap fallback-shell grid=90x21 cursor=49,6
|1: New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
|2: /tmp/pardes-snap/shellset/cwd/cmd.txt Save /tmp/pardes-snap/shellset/cwd Tty+bash Sav
diff --git a/test/snapshots/shellset.snap b/test/snapshots/shellset.snap
index d2bacf38..7d213a84 100644
--- a/test/snapshots/shellset.snap
+++ b/test/snapshots/shellset.snap
@@ -1,12 +1,12 @@
# The Shell builtin names the binary the NEXT terminal execs. What is pinned
# here is the case that has to hold on a machine you did not set up: the name
-# resolves to nothing, and the pane opens anyway.
+# resolves to nothing, so Shell refuses it and says why on the message row,
+# and the next pane opens anyway with the shell that was set before.
#
-# That is not a corner — it is the DEFAULT's failure mode. pardes ships
-# `Shell fish` as the default, and a box without fish must still get a working
-# shell rather than a pane whose child dies at exec and shows one EOF. So the
-# lookup falls back, and this is the proof: a name nothing resolves to, and the
-# new pane still greets and lists like any other.
+# A default $SHELL that names nothing executable falls back to /bin/sh
+# (config.defaultShell), and the host's lookup still falls back at spawn for
+# a shell that went away after it was set: a pane never gets a child that
+# dies at exec and shows one EOF.
#
# The rest of the feature is unit-tested rather than pinned here, on purpose.
# Which family a binary belongs to, what argv each one gets and where the
diff --git a/test/snapshots/shrinktag.golden b/test/snapshots/shrinktag.golden
index 552722f9..9aa29e11 100644
--- a/test/snapshots/shrinktag.golden
+++ b/test/snapshots/shrinktag.golden
@@ -21,23 +21,25 @@
| ls
| w.txt
== snap shrunk grid=100x31 cursor=2,9
-|3: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo de Filter Collapse Del
-|4: de Filter Collapse Del ls
-|5: ls w.txt
-|6: w.txt
-|8:
+|4: w.txt ls
+|5: w.txt
+|6: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
+|7: de Filter Collapse Del
+|8: ls
+|9: w.txt
|17:
|18:
|19:
|20:
== snap move-preview grid=100x31 cursor=2,9
-|8: ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌▌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
+|8: ls ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌▌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
== snap restored grid=100x31 cursor=2,9
-|3: de Filter Collapse Del de Filter Collapse Del
|4: ls ls
|5: w.txt w.txt
|6:
+|7:
|8: echo one; echo two
+|9:
|17: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
|18: de Filter Collapse Del
|19: ls
@@ -47,4 +49,6 @@
|18:
|19:
|20:
-|30: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
+|27: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
+|28: de Filter Collapse Del
+|29: w.txt
diff --git a/test/snapshots/shrinktag.snap b/test/snapshots/shrinktag.snap
index 31edad14..5264be74 100644
--- a/test/snapshots/shrinktag.snap
+++ b/test/snapshots/shrinktag.snap
@@ -1,8 +1,8 @@
-# a pane can shrink to just its tagline: the pane below dragged by its grip up
-# to the tag row (0 body rows) -- a terminal has no rule between panes, so
-# the grip is the handle, as acme's button is -- the gutter box still grabs a
-# move, and dragging back down restores the body intact (the hidden pane's
-# pty/vt is never resized)
+# a terminal shrinks only to its tag and 2 body rows (a text pane would go
+# to its tag alone): the pane below dragged by its grip up to the tag row
+# stops there -- a terminal has no rule between panes, so the grip is the
+# handle, as acme's button is -- the gutter box still grabs a move, and
+# dragging back down restores the body intact
file w.txt x
start 31 100 -n 3
wait 8000 w.txt
@@ -18,7 +18,7 @@ key esc
settle 100
stable 400 5000
snap before
-# drag the lower pane's grip up under pane 0's tag row: tag-only pane
+# drag the lower pane's grip up under pane 0's tag row: it keeps 2 body rows
press left 1 18
drag left 1 3
release left 1 3
@@ -34,14 +34,16 @@ snap move-preview
drag left 20 3
release left 20 3
stable 400 5000
-# re-enlarge: the lower pane's grip, now under pane 0's tag, pulled back down
-press left 1 4
+# re-enlarge: the lower pane's grip, now under pane 0's 2 body rows, pulled
+# back down
+press left 1 7
drag left 1 18
release left 1 18
motion 40 25
stable 500 8000
snap restored
-# the bottom pane can shrink to ITS tag too: its grip dragged past the bottom
+# the bottom pane stops at ITS tag and 2 rows too: its grip dragged past the
+# bottom
press left 1 18
drag left 1 31
release left 1 31
diff --git a/test/snapshots/splitparent.golden b/test/snapshots/splitparent.golden
index e64df572..e626cea7 100644
--- a/test/snapshots/splitparent.golden
+++ b/test/snapshots/splitparent.golden
@@ -2,38 +2,34 @@
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
-| Mode Filter Collapse Del ls
+| Mode Filter Collapse Del Mode Filter Collapse Del
+| ls ls
+|
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+| Mode Filter Collapse Del
| ls
-== snap swap-parent grid=100x31 cursor=4,9
-|5:
-|6: /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-|7: Mode Filter Collapse Del
-|8: ls
-== snap tallest grid=100x6 cursor=54,5
+== snap swap-parent grid=100x31 cursor=4,13
+|10: /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+|11: Mode Filter Collapse Del
+|12: ls
+== snap tallest grid=100x6 cursor=4,5
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-== snap regrown grid=100x31 cursor=54,20
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save ls
+| e in that column: each keeps its tag and 2 rows
+== snap regrown grid=100x31 cursor=4,13
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save Mode Filter Collapse Del
-| Mode Filter Collapse Del ls
+| Mode Filter Collapse Del Mode Filter Collapse Del
+| ls ls
|
| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
| Mode Filter Collapse Del
| ls
|
-|
-|
-|
-|
-|
-|
-|
-| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
-| Mode Filter Collapse Del
-| ls
+| /tmp/pardes-snap/splitparent/cwd Tty+bash Save
+| Mode Filter Collapse Del
+| e in that column: each keeps its tag and 2 rows
diff --git a/test/snapshots/splitparent.snap b/test/snapshots/splitparent.snap
index b9038af2..93ed9e21 100644
--- a/test/snapshots/splitparent.snap
+++ b/test/snapshots/splitparent.snap
@@ -1,25 +1,29 @@
-# a new pane never opens under a too-short parent: Alt-n from a tag-only pane
+# a new pane never opens under a too-short parent: Alt-n from a pane too short to halve
# splits the tall sibling in its column instead; when NO pane could leave the
-# newcomer 2 body rows, the tallest one anywhere is split
+# newcomer 2 body rows, the tallest one anywhere is split, and when that one
+# cannot keep its own tag and 2 rows either, the pane is refused
# pins the pardes placement rule (Placement pardes), which it covers
config Placement pardes
start 31 100 -n 3
stable 700 20000
-# shrink pane 0 (top-left) to just its tagline: the pane below's grip dragged
-# up under it (a terminal has no rule between panes; the grip is the handle)
+# shrink pane 0 (top-left), a terminal, as far as it goes, its tag and 2 body
+# rows: the pane below's grip dragged up under its tag (a terminal has no
+# rule between panes; the grip is the handle)
press left 1 18
drag left 1 3
release left 1 3
motion 40 25
stable 500 8000
snap shrunk
-# Alt-n from the tag-only pane: the new shell opens below the tall sibling
+# Alt-n from the short pane: the new shell opens below the tall sibling
# underneath it, not as another 1-row sliver
key a-n
stable 700 15000
snap swap-parent
-# squeeze the grid until no pane is 5 rows tall, then Alt-n again: the
-# tallest pane (right column) is split even though the active one is left
+# squeeze the grid until no pane can give a new one its tag and 2 rows, then
+# Alt-n again: the new shell is refused, and the message row says why (before
+# every pane had to keep only a tag and one row, the tallest pane anywhere was
+# split here)
resize 6 100
stable 700 15000
key a-n
diff --git a/test/snapshots/tinywin.golden b/test/snapshots/tinywin.golden
index d24ab0c5..c847a2f1 100644
--- a/test/snapshots/tinywin.golden
+++ b/test/snapshots/tinywin.golden
@@ -20,13 +20,13 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/tinywin/cwd/+New Save Tty Collapse Del
| 1
== snap shrunk grid=60x4 cursor=59,3
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor
| New Tty Find Grep Joincol Delcol
|$ /tmp/pardes-snap/tinywin/cwd Tty+bash Save Mode Filter Co
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/tinywin/cwd/+New Save Tty Collapse Del
== snap columns grid=100x31 cursor=23,3
|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit
| New Tty Find Grep Joincol Delcol
@@ -49,7 +49,7 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/tinywin/cwd/+New Save Tty Collapse Del
| 1
== snap narrow grid=4x31 cursor=2,20
|Newc
@@ -74,10 +74,10 @@
| cn
|
| /
-| +
-| N
-| e
-| w
-|
-| S
+| t
+| m
+| p
+| /
+| p
| a
+| r
diff --git a/test/snapshots/ttyhelp.golden b/test/snapshots/ttyhelp.golden
index cd7cb946..8b8d4195 100644
--- a/test/snapshots/ttyhelp.golden
+++ b/test/snapshots/ttyhelp.golden
@@ -20,7 +20,7 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/ttyhelp/cwd/+New Save Tty Collapse Del
| 1
== snap helped grid=100x31 cursor=7,6
|5: /tmp/pardes-snap/ttyhelp/cwd/+Help Save Tty Collapse Del
diff --git a/test/snapshots/ttylook.golden b/test/snapshots/ttylook.golden
index 05cace52..f2f37f9d 100644
--- a/test/snapshots/ttylook.golden
+++ b/test/snapshots/ttylook.golden
@@ -66,6 +66,6 @@
== snap fileopen grid=120x31 cursor=3,4
|22: /tmp/pardes-snap/ttylook/cwd/+Search Save Tty Collapse De
|23: l LocationsConfig
-|24: 1 @p0:1:1-2
+|24: 1 @p1:1:1-2
|25: 2 ls
|26: 3
diff --git a/test/snapshots/ttyonly.golden b/test/snapshots/ttyonly.golden
index 85a61d35..77f7dbb7 100644
--- a/test/snapshots/ttyonly.golden
+++ b/test/snapshots/ttyonly.golden
@@ -20,7 +20,7 @@
|
|
|
-| /+New Save Tty Collapse Del
+| /tmp/pardes-snap/ttyonly/cwd/+New Save Tty Collapse Del
| 1
== style ttyonly grid=100x31
|0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824,
@@ -44,7 +44,7 @@
|18: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
|19: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
|20: 0-0 d,#608269, 1-1 d,#0d1410, 2-99 #e5eee5,#0d1410,
-|21: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-3 #b2c3b8,#1d2824, 4-7 #d3b0d9,#1d2824, 8-29 #b2c3b8,#1d2824, 30-99 d,#1d2824,
+|21: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-31 #b2c3b8,#1d2824, 32-35 #d3b0d9,#1d2824, 36-57 #b2c3b8,#1d2824, 58-99 d,#1d2824,
|22: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410,
|23: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410,
|24: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410,