summaryrefslogtreecommitdiff
path: root/docs/fs.md
blob: 85be803d203b76f43e54713ad0e5ef5f87c3ed18 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
# Filesystem

Every native session serves 9P2000 (not .u, not .L) as a control
filesystem, in acme's manner: panes, columns, tags and the session are files.
This page is the reference for what each file does. The served
[`/README`](../src/fs-help.txt) is its one-screen summary.

## Connecting

The socket is `$XDG_RUNTIME_DIR/pardes-9p-<name>.sock` (else under
`~/.local/state/pardes`), `<name>` being the pid, the `--detach=NAME`, or
`--9p=<name>`. A socket in the runtime directory is also posted in the 9P
registry as `$XDG_RUNTIME_DIR/9p/pardes/<name>` (a symlink to the socket;
[cloud9.md](cloud9.md#the-posted-9p-registry)).

Pane shells get `PARDES_PID` (the editor's pid), `PARDES_9P` (the socket)
and `PARDES_PANE` (their pane's serial).

**Forwarding.** `pardes FILE` run in a pane (a live `PARDES_PID`, with
`PARDES_9P` and `PARDES_PANE`) writes FILE to that pane's `look` and returns
at once, as acme's `B` does. A FILE not there yet (`pardes notes/new.txt`)
opens a new pane named for it, empty, and its Save creates the file, making
its directories first when they are not there either. A session that
answers never gets a nested editor in its pane: what it refuses (a bad
name, a pane it has not) is printed, `pardes: <file>: <why>`, and the
launch exits 1, leaving no pane behind. A missing `PARDES_9P`/`PARDES_PANE`,
or a session that does not answer, starts a separate editor instead. Bare
`pardes` in a pane refuses and names `--nested`.

`--wait` (`-w`) returns when the pane that shows FILE is deleted (exit 0) or
the session goes away (exit 1), as acme's `E` does. Use
`EDITOR='pardes --wait'` (`GIT_EDITOR` follows `EDITOR`), so fish's Ctrl-O,
`git commit` and `crontab -e` read the file after you close its pane.
`--nested` runs a separate session whose shells do not forward to it.

**Clients.**

```sh
9p -a "unix!$PARDES_9P" read index                     # plan9port, no mount
9ns --unix "$PARDES_9P" -- sh -c 'cat "$NINE_MOUNT/index"'   # private mount
9ns --mntgen                                           # the whole registry at /mnt/9p
```

Under `9ns --unix` the session is `$NINE_MOUNT` itself and exists only inside
that command. Under `9ns --mntgen` every posted session is
`$NINE_MOUNT/pardes/<pid or NAME>/`; take the name from `$PARDES_9P`. A dead
session's entry stays listed and answers `Input/output error`, so name the
session rather than globbing. `Tty9p` gives one pane's shell a kernel mount
at `$PARDES_MOUNT` ([v9fs.md](v9fs.md)).

plan9port's `9p write` always opens with OTRUNC, so `echo x | 9p write
pane/3/body` replaces the whole body where acme would append. Append with
`>>` through a mount.

For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html)
use `version=9p2000,cache=none,access=any`, `trans=unix` (or `trans=tcp`
with `port=`), `uname`, `dfltuid` and `dfltgid` for the local user, and an
empty `aname`.

**Listeners.** `--9p-tcp='tcp!127.0.0.1!5640'` adds TCP;
`--9p-quic='quic!127.0.0.1!5641'` adds QUIC (build with `-Dquic=true`,
OpenSSL 3.6+; ALPN `pardes-9p`, an ephemeral TLS identity, no peer
verification). Addresses are numeric IPv4/IPv6; port 0 picks one; `/listeners`
reads them back. Every connection has full session access, `/os` included,
and TCP is unencrypted: use loopback. Unix and TCP share 16 connection slots;
a 17th client's Tversion gets `too many connections` (and the log
`err - 9p: too many connections (N turned away)`). QUIC has 16 of its own.
Plan9port and v9fs need a userspace bridge for QUIC.

**Look paths and mounts.** Look resolves the OS filesystem first, then the
editor's own tree. Explicit paths skip that search:

| Look path | Meaning | Served path |
|---|---|---|
| `/n/os/proc/self` | the host filesystem | `/os/proc/self` |
| `/n/self/pane/2/body`, `/virtual/pane/2/body` | this session's tree | `/pane/2/body` |
| `/virtual/src/pardes.zig` | sources embedded with `-Dembed-sources=true` | `/src/pardes.zig` |
| `/n/peer/pane/2/body` | a mounted session | the peer's `/pane/2/body` |

`--mount=peer=work` or `Mount peer <dial>` mounts a session name, an absolute
socket path, `unix!/path`, `tcp!IP!port` or `quic!IP!port`; `Unmount peer`
removes it. Mount dials at once and fails if nothing answers (`dial failed:
no answer`, `timed out`, `hung up`). There are eight named mounts; `os` and
`self` are reserved. Unmount refuses a mount a pane, a working directory or
a pending Save still uses. Mounts are dumped.

A session may open its own tree through a mount (a Look at
`$m/pane/2/body` from the editor serving `$m`): requests are answered on the
connection's task while the editor waits in its syscall. Through QUIC that
still hangs.

## The tree

```
/README        the one-screen guide (src/fs-help.txt)
/index         a line per pane: serial kind dirty name column
/status        pid, version, panes
/look /exec    write a line: a right / middle click at the active pane; read: the serials touched
/log           the event log; write `follow` to wait for more
/screen        the rendered screen as JSON
/listeners     dial addresses
/focus         the serial of the pane with the keyboard; write one to move it
/ctl           settings and session builtins
/commands      every builtin, one a line
/recent        files opened lately: open|closed <path>
/layout        a line per column, then `active <serial>`
/tag /tagexec  the workspace tag, and a word clicked in it
/col/<n>/      tag ctl exec of column <n>; rmdir closes an empty one
/pane/new      open it to make a pane; read answers the serial
/pane/<n>/     name body tag ctl addr dot limit data xdata sel dirty mark scroll
               errors event look exec tagexec, and pty/{ctl,status,data,run} on terminals;
               rmdir closes the pane
/os/           the host filesystem
/src/          the editor's sources (only with -Dembed-sources=true)
```

Panes and columns are named by serials the editor gives: stable while they
live, never reused. Nothing is created by a listing, stat, walk or read:
only an open of `/pane/new` makes a pane (so `ls -l` and `find` are safe),
only `rmdir` of `/pane/<n>` or an empty `/col/<n>` removes. Tcreate is
refused everywhere.

`/index` rows are `serial kind dirty name column`, kind `text`, `term`,
`pdf` or `image`, the name `<dir>/+New` for an unnamed scratch. Names may
hold blanks, so split `head, col = row.rsplit(maxsplit=1)`, then
`serial, kind, dirty, name = head.split(maxsplit=3)`. Names in `/index`,
the log and a terminal's tag are escaped: a newline `\n`, a backslash `\\`, a byte that
is not UTF-8 `\xNN`.

## Rules for every file

**Failure.** A write fails whenever what it asked for fails, and logs one
`err <serial|-> <file>: <why>` record, with no `msg`. Only writes log: a
refused open or truncation (an OTRUNC open such as `> data` after a failed
`addr`), create or remove answers its error alone, as do a write to
`pane/new` (`permission denied`) and a write on a read-only fid (`bad use
of fid`). Errors are words (Plan 9's where pardes has none of its own),
never a C library string. Through 9ns the kernel sees an errno 9ns reads
from those words (cloud9 `9ns/src/nine.zig`, `enameToErrno`): `control
message`, `invalid` or `bad ` is EINVAL (malformed input); `no such`, `not
found` ENOENT; `in use` EBUSY; `no space` ENOSPC; `denied` EACCES; anything
else, such as `no match for regexp`, `address out of range`, `Modified` or
an `Edit` command pardes leaves out (`w`, `e`, `r`, `|`: `w is a sam command
pardes's Edit leaves out`), EIO; no refusal reads as EOPNOTSUPP. The `err` record has the words; a shell sees
only the errno, most often `Invalid argument` or `Input/output error`.

Not failures: a look that finds nothing (it answers nothing and logs one
`err`), an Edit `x` that matches nothing, `Undo` with nothing to undo (a
`msg`), and a command pane's command, which ends in its own time with an
`exit` record.

**Command lines.** `look`, `exec`, `tagexec`, the three kinds of `ctl` and a
column's `exec` take one command a line. The whole write is checked first
(a control character other than a tab fails it all, EINVAL); then lines run
in order, and a failing line fails the write after the lines before it took
effect, as acme's ctl does. Blank lines are skipped. A mount cuts a big
write into pieces of at most one message (msize 64 KiB, 65536 negotiated,
less the header), and
each line runs once it is whole. A write that does not fill its message is
whole, so its last line runs even without a newline (`printf Save > exec`),
unless it is a multiple of 4096 bytes: that is where a writer's buffer (stdio,
a mount's page cache) filled and cut a line, so its tail waits for the next
write or the close. An `Edit` whose `{` or
`a`/`c`/`i` text is still open waits for the next write on that open. A
line held to the close (a 4096-multiple write's tail, an `Edit` block never
ended) runs there, and its failure is only in the log, as its `err` record
(``err <serial> ctl: unmatched `{'`` or `a, c or i text not ended by a .
line`): the close itself reports no error, and the write that sent it had
already succeeded. A script that needs a line's result ends the write with
a newline, so the line runs, and fails, with its write. A line held past
1 MiB is refused.

**Answers.** Reading `look`, `exec` or `tagexec` answers the serials the
last command made, or else the pane it acted on or focused, one a line;
nothing when it did none of these (`Newcol` at `/tagexec`). An
open that wrote reads its own last answer; an open that never wrote reads
the session's last. A read is a stream: once read, the next read on that
fid is EOF until the next write (or seek to 0). With other clients about,
write and read on one open:
`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`.

**Snapshots.** `/index`, `/layout`, `/recent`, `/commands`, `/status`,
`/listeners`, a read-only `ctl`, `/log`, `/screen` and a terminal's `body`
freeze at the open, so one read in several chunks never splices two moments;
open again for now. Such an open, or a `run`, `event` or `pty/data` open,
takes one of 64 open records; past that the open fails `too many open
files`.

**Held reads.** A read with nothing to give yet (a followed `log`, `event`,
`pty/data`, `pty/run` before its answer) waits in the editor and is answered
when news comes. A second read on that open meanwhile fails `file in use`.
A read the client flushed is dropped. One connection holds at most 128
reads at once; the next is refused `too many reads waiting: 128`. Every
held read is on an open that keeps state, and those are 64 in the session
(above), so 64 is the real cap on reads held at once, through one
connection or many. A mount (9ns) is one connection, and it keeps
answering everything else beside its held reads. Through a FUSE mount bash's
`read -t` cannot time out: wrap the loop in `timeout N`.

**Stats.** A file whose text is kept has its real length: a pane's
`body`, `tag`, `name`, `ctl`, `sel` and the range and flag files, the
workspace's `tag`, the root `ctl`, `status`, `commands`, `README`, the
`look`/`exec` answers, `focus`; `event` and `pty/data` the next record's,
zero when none waits; `log` what an open would freeze. A view generated by
each read stats 0, as acme's do: `screen`, `data`, `xdata`, `index`,
`layout`, `recent`, `listeners`, `pane/new`. Read those to the end rather
than trust a length (`cat` does). The qid
version of `body`, `data` and `xdata` is the pane's revision, so a stat sees
an edit land. Modes are 0644/0666, 0444 read-only, 0222 write-only.

## look and exec

A line written to `look` is a right click on it, on the line as written:
its leading blanks are its own (`    return x` finds that indented line, and
a diff's blank context line ` ` is a look too); only its newline and `\r`
go. An `exec` line's blanks at either end are trimmed.

- a path opens the file (the pane already showing it, if any); `file:12`
  selects line 12, newline included; `file:12:5` puts the caret at line 12,
  byte column 5; `file:<addr>` takes any address (below), **evaluated from
  the file's dot**: `file:/re/` finds the next match after the selection,
  `file:0/re/` the first. `:addr` addresses the pane itself. A leading `~`
  is the home directory ($HOME, else the passwd entry's; `~user` that
  user's) here and wherever a path is typed (`name`, `Save`, `ThemeFile`,
  `DumpDir`, `Restore`, `pardes '~/x'`), even beside a file named `~`: write
  `./~` for that. A path to no file is a miss, as a search that finds
  nothing is: said on the message row and logged as an `err`, the write
  still answered. A file that is there but will not open (no permission to
  read it, say) fails the write, with why.
- a relative path is resolved where the click was, then where you have
  been: first against the looking pane's own directory, then against the
  directory of each pane in the jump list, most recent first, each
  directory tried once; the first that names a file (or a pane open on
  that path) wins. A pane never visited (not in the jump list) is not
  searched. `./x` and `../x` are the looking pane's directory's alone.
- `@p<serial>:<addr>` addresses a pane by serial, a terminal's logical lines
  too.
- a directory types `ls` into a terminal idle there, else opens one there.
- a URL opens in the browser.
- in a diff pane, a whole line of the diff is a look at the `path:line` it
  names, as a right click on its first column is
  ([tags.md](tags.md#reviewing-diffs)).
- a plain word selects its next place after dot, wrapping (`LookWord list`
  on the root ctl lists every place in a `+Search` pane instead). In a
  terminal a word is always listed, rows spelled `@p3:12:5-9`.

A miss logs `err <serial> look: ...` (`no match for "zzq:#3"` quoting what
was written when nothing by that name exists; `<path>:99 has no line 99`
for a line past a file's end, `<path> has no page 99` for a PDF's page;
`<path>: no match for regexp` or `<path>: address out of range` when an
address fails), opens nothing, and leaves `look` reading empty; the write
succeeds. A path too long to repeat whole gives up its middle to `…`.

A line written to `exec` is a middle click:

- a builtin word runs (`/commands` lists them): `Save`, `Del`, `New`, `Tty
  [shell]`, `Msg text`, `Find`, `Grep`, `Edit ...`, `Mount`, ...
  A builtin that needs its argument (`Msg`, `Mount`, `Find`) written bare is
  `wrong #args in control message "Msg"`, and so is one that takes none
  written with one (`Config extra`).
- a line starting with `#` is a comment, as in a shell: it runs as
  nothing, silently, here and in every ctl.
- the language server's words ask about a file pane's text at its cursor
  (set it with `addr` and `dot=addr` first): `Hover` fills `+Hover`,
  `Rename new` renames the symbol in the file and says how many ranges it
  changed (on the message row, and so in /log) without listing them; one
  that finds nothing to rename fails; a rename the server spreads over
  other files lists them in `+Search`,
  `Diagnostics` and `Symbols` list the file's in `+Search`, `Lspinfo` says
  which server serves the file and its state, and `Lspwhy` narrates the
  last query step by step (in `+Lsp`), to tell why it found nothing. The
  write returns once the answer is in; a pane with no file is refused.
- acme's words run as pardes's where it has one (`Put` is `Save`, `Delete`
  a `Del` that does not ask); the rest (`Get`, `Putall`, `Snarf`, `Cut`,
  `Paste`, `Zerox`, `Sort`, `Load`, `ID`, `Send`, `Tab`, `Indent`, `Local`,
  `Incl`, `Abort`) are refused, `invalid:
  acme's Get is not a pardes builtin: ...`, never run as commands. So is a
  GUI-only builtin (`Fonts`) on another frontend.
- anything else is a command line, at most 1024 bytes. At a terminal idle
  at an empty prompt it is typed into that shell. Anywhere else it runs as
  a **command pane**: a terminal running the root ctl's `Shell` (`$SHELL`,
  else `/bin/sh`) with `-c` and the line, in the pane's directory, with job
  control on. Its tag reads `<dir> (<line>) running`, then `exit N`; the log
  says `run <serial> <line>` and `exit <serial> <N|?>`; `exec` reads back its
  serial. A typo ends `exit 127`. The directory's next command reuses a
  finished command pane, below what it showed; one still running gets a
  second pane. A reused pane's `body` keeps every earlier run above a
  `% <line>` row naming each command, so to take only the last run's
  output read from after the last `% ` row:
  `awk '/^% /{out=""; next} {out = out $0 "\n"} END {printf "%s", out}' body`.
  From a column's or the workspace's tag (or `/tagexec`, `col/<n>/exec`)
  a command always runs as a command pane, in the session's directory. From a pane whose directory is gone nothing runs: `exec:
  <dir>: no such directory` (ENOENT).

The root's `look` and `exec` act at the active pane and log as that pane's
(with no pane at all, in the session's directory: a look opens its file,
making a column as `New` does, and an exec runs its command there);
`/pane/<n>/look` and `exec` at pane n; `/tagexec` and `/col/<n>/exec`
click in the workspace's or that column's tag, run commands in the session's
directory, and log as `-`. A pane's word (`Undo`, `Msg`, `Save`) is refused
at `/tagexec` and a column's exec: `not a session control message "Undo":
write it to pane/<n>/ctl`.

A background job (`&`) outlives a command that exits on its own; its output
goes on below `exit N` until it lets go of the pty. `Kill` (root ctl)
stops the commands pardes started: bare, all; `Kill make ls`, those whose
line starts with one of the words. For a command pane it signals the whole
line, `&` jobs included; for a line typed into a shell only the foreground
job (SIGTERM), and the shell decides the rest. With nothing running, named
or not, it says `Kill: nothing running` and the write succeeds; words that
name none of what runs fail it (`Kill: no running command has that first
word`). Kill does not reach a REPL's code: use `sig INT` on
its `pty/ctl`.

## The root ctl

Reading `/ctl` gives every setting, one a line, in the words a write takes
(`Theme orchard`, `Verbose on`, `Placement acme`, `DumpDir <dir>`,
`Shell /bin/bash`, ...), so writing back what it reads changes nothing.
Writes take settings and session builtins (`scope = .session` in
`src/builtins.zig`), acting at the pane with the keyboard:

- A setting written bare steps to its next value (a switch flips; so do
  `Placement`, `BootShell`, `Crt`). A value it does not take is `bad value in
  control message; ...` naming what it takes; `/commands` lists them. A
  setting the frontend cannot show is refused (`Lift is GUI-only, invalid
  here`).
- `Newcol` makes an empty column right of the keyboard's, halving the active
  column. `Joincol` folds the keyboard's column into the one on its right
  (its panes go below that column's), `Joincol: no column to the right`.
- `Exit` quits. It refuses once while panes hold unsaved text: one `unsaved
  <serial> <name>` record per pane, then the write fails `<name>: Modified
  (Exit again to discard)` or `4 unsaved panes: Modified (Exit again to
  discard)` (EIO), and the list stays in a `+Unsaved` pane. The same word
  again with nothing edited since discards; after more editing it refuses
  again, naming only the panes edited since. `Restore`, `Del`, `Delcol` and
  a pane's `get` refuse the same way with their own word. A `+New` scratch
  under 100 bytes, or a command's output, is never asked about.
- `Dump` writes `pardes-<date>-<time>.zon` (UTC) in `DumpDir`
  ([config.md](config.md#dumps)), logs `dump <path>`, and adds a `Restore
  <path>` word naming it to the workspace tag (the last dump's, replacing
  an earlier one's). `Restore [path]`
  replaces every pane (bare: the last dump this session wrote). The Restore
  write is answered, then **every connection is hung up**: dial again, and
  restart a 9ns mount. The new log has a `new` per pane, `restore <path>`,
  then `restored <old> <new>` per pane and `restoredcol <old> <new>` per
  column. Undo history, the jump list and REPL bindings are not restored; a command pane
  comes back showing how it ended (`exit ?` if it was running) and does not
  run again.
- `Kill [word...]` (above), `Mount name dial`, `Unmount name`, `Theme x`,
  `size <cols> <rows>`.
- `size C R` sizes a `--detach` session no frontend is attached to (160x50
  until then): from 20x6 to 4096x4096, else `invalid size`; refused while a
  frontend owns the size, and when a column would lose its panes' minimum
  rows (`size: too small for the panes, each its tag and 2 rows`).

A write is refused whole, before anything runs, in Plan 9's words:
`unknown control message "X"`, `wrong #args in control message "X"`,
`bad value in control message ...`, or a word of the other ctl: `not a
session control message "Undo": write it to pane/<n>/ctl`, `... "Delcol":
write it to col/<serial>/ctl`, `not a window control message "X": write it
to /ctl`. A builtin that would open a prompt (`Save` on a scratch) fails
`control message needs its argument "Save"`. A line that fails as it runs
fails with the editor's words (`Mount: already mounted`, `Save /root/x:
access denied`), changing nothing.

## Columns and tags

`/layout` has a line per column, left to right: `serial index x width
current|notcurrent empty|full pane-serials...`, then `active <serial>`
(acme's activecol: where `pane/new` and a look put the next pane; `-` when
none). `current` is the column with the keyboard now, which may differ
from the active one. `/index`'s last field is each pane's column serial.

A session holds 16 columns (`no space for a column: 16 max`, ENOSPC), each at
least 10 cells wide, so 16 wants a window 160 cells wide or more. `Newcol`
halves the column it is run from, and one under 20 cells does not split
(`this one is too narrow to split`): reach 16 by writing `Newcol` to the
widest column's `exec` each time, not to the root's, which keeps halving the
one just made. `Newcol` is refused as well when a narrower column would
wrap its panes' tags onto more rows and leave one under its tag and two
rows (`Newcol: no space for a column: the panes' tags would not fit`); a
refused `Newcol` logs its `err` alone and uses up no column serial.

`/col/<n>/ctl` takes `Delcol`, `Joincol`, `New` and `Tty` for that column;
`/col/<n>/exec` is a click in its tag; `rmdir /col/<n>` closes an empty column
(one with panes: ENOTEMPTY). A column may be empty, as in acme: `Newcol`
makes one, and closing its last pane leaves it (the keyboard goes to its
tag, `focus` reads empty, the log says only `del`). Closing the session's
last pane quits pardes. The log says `newcol <serial>` and `delcol <serial>`.

`tag` files (`/tag`, `/col/<n>/tag`, `/pane/<n>/tag`) read the whole tag,
with no newline after it.
A pane's starts with its computed path (an image's with its mode words, a
PDF's with its page), then the editable text. `>` replaces the editable text
(the default words too: `echo Make > tag` leaves only `Make`) and drops the
one newline that ends it; `>>` appends, so `printf ' Make' >> tag` (the
blank matters; `echo` would start a second line). A pane tag may hold
several lines; a column or workspace tag is one, a newline written into it
becoming a space. Control characters other than tab, DEL, C1 controls and
non-UTF-8 bytes are refused (`invalid tag text`). The editable text is at
most 4096 bytes (`no space: over 4096 bytes`, ENOSPC; the log says `err <serial> tag: no space: ...`). All three kinds
take the same checks, whole or not at all, and a `>` whose write is refused
changes nothing: its truncation is done with the write that fits, or at the
close (or a read) when none came. Each write stands on its own: in a `>`
cut into several writes, those taken before a refused one stay in the
tag; only the refused write changes nothing. A clear is an ordinary edit
and `u` in the tag undoes it. [tags.md](tags.md) covers tags on screen.

## Panes

**Making and closing.** Opening `/pane/new` makes a scratch `<dir>/+New`
(the session's directory), and reading that open answers its serial:
`n=$(cat $m/pane/new)`. Each open makes another pane; two reads of one open
answer the same serial. It goes where acme's makenewwindow puts a window
([tags.md](tags.md#where-new-panes-go)): in the active column, filling it
if empty, else the bottom half of its last pane. A session holds 64 panes
(`no space for a pane: 64 max`), and every pane keeps its tag and 2 rows
(`no space for a pane in that column: each keeps its tag and 2 rows`); both
are ENOSPC, for this open and for a look, exec, `New` or `Tty` alike. A
`pane/new` whose column is full takes its rows from a pane in another column
that has them, last column first, as acme does, and is refused only when
no pane anywhere can give them.
`rmdir /pane/<n>` closes the pane, unsaved or not.

**`name`** reads the file name (a terminal's directory); a write renames the
buffer (relative to the pane's directory) and marks nothing dirty; `Save`
then writes under the new name. A name takes any byte a file name can
hold, blanks, control bytes and bytes not UTF-8 included, so what `name`
reads writes back as it was; refused (EINVAL) are only a second line and a
NUL (`bad character in file name: a NUL`). Up to 255 bytes a component.
The ctl word `name x` takes all after its one blank; a second blank there
is refused rather than read as the name's first byte. A directory (`/`, `~`, `foo/`) is no file name: refused, EISDIR
(`name: /home/u is a directory, not a file`).

**`body`** reads the text; a write appends; `>` (OTRUNC) replaces it all.
A terminal's body is its history as plain text in logical lines (wrapped
rows joined), frozen per open; writing it sends input to the child as typed
keys, never a paste: no bracketed-paste marks around it, even when the
program asked for them, so a newline in it is Enter. A PDF's
body is the text layer of the page shown, read-only: it is no text of the
pane's, so `addr`, `data`, `dot` and a `file:<addr>` look do not address
it (a PDF's `:<n>` is its page); images and PDFs take no write
(`this pane has no text`).

**`sel`** reads the selected text; a write replaces it. **`errors`** is
write-only: text appended to the directory's `+Errors` pane (logged as
`msg` records when no column has room).

**`focus`** (root): a serial written gives that pane the keyboard and makes
its column active (a folded pane stays folded); `no such pane`, or
`ill-formed control message` for a non-number. It reads empty while a
column or workspace tag has the keyboard.

**Pane ctl.** Reading it gives acme's status line: serial, tag length, body
length, isdir (0), dirty, width in cells, font, tab width, undo available,
redo available, then `current`/`notcurrent` and a REPL's id if bound. It
takes:

- the pane builtins: `Del` (`Del k`/`Del j`, or `DelAbove`/`DelBelow`, give
  its rows to the pane above or below), `Save [path]` (making the
  directories the file goes in first, whichever name it writes), `Collapse` (fold),
  `Undo`/`Redo` (256 steps each), `Find pat`, `Edit ...`, `Tty [shell]`
  (a new terminal pane in its directory), and the column words acting on
  its column: `Delcol`, `Left`/`Right`/`Up`/`Down`.
- `get`: reload from the file (refused once over unsaved edits, `<name>:
  Modified (get again to discard)`).
- `lock`/`unlock` (acme's). The lock binds only clients that take it and
  belongs to the open that wrote it: `exec 3>$pane/ctl; echo lock >&3; ...;
  exec 3>&-`. A `lock` another open holds fails at once, `file in use`
  (EBUSY): retry.
- `answer <choice>` to the question the pane asks on its notice band, logged
  `ask <serial> <what> <choices>` (`ask 4 del k j`, `ask 4 repl a b`, `ask 4
  save path`); `answer -` takes it back.
- acme's lowercase ctl words, done by what replaces each: `name x`, `put`
  (Save), `clean`/`dirty`, `del`, `delete` (no asking), `dot=addr`,
  `addr=dot`, `limit=addr`, `mark`/`nomark`, `show`, `cleartag`. `dump`,
  `dumpdir`, `font`, `menu`, `nomenu` are refused, EINVAL. These lowercase
  words are ctl-only: written to an `exec`, `del` is a command line.

A file changed on disk reloads by itself only when the buffer has no unsaved
edits; otherwise it stays dirty, says `<name> changed on disk (get reloads
it, Save overwrites it)`, logs `changed <serial>`, and its next `Save`
warns once.

### Addresses and data

`addr`, `dot` and `limit` read the pair of byte offsets they take, so
copying one onto another (`cp $p/addr $p/dot`) is acme's `dot=addr`. A
write is a pair or an address expression; a pair is checked as an address
is, `addresses out of order` (`5 2`) or `address out of range` (past the
text), never clamped. `addr` names where `data` reads
(to the end of text) and the range `xdata` reads; `dot` is the selection
(moving it scrolls the pane there); `limit` bounds the end of a forward
search and reads empty until set. Truncating `dot` empties it, truncating
`limit` lifts it.

- A write to `data` or `xdata` **replaces** the `addr` range (`>` and `>>`
  alike); `: > data` deletes it. Truncating `data` is pardes's own (acme
  ignores OTRUNC there); only truncating `body` empties the buffer.
- A write leaves `addr` just past what it wrote, so a second `echo x > data`
  inserts after the first: write `addr` before each replacement. A read of
  `data`/`xdata` moves `addr` past what it read.
- `addr` belongs to the pane, not the client, and neither an open nor a
  truncation resets it (acme resets on first open): each `echo /re/ > addr`
  searches on from the last, so a find loop advances. Write `0` to start
  at the top; searches wrap, so stop a loop when the address comes back or
  set `limit`.
- A failed address leaves **no address**: `addr` reads empty and `data`/
  `xdata` refuse (`no address: the last one written to addr failed`) until
  a standalone address (`2`, `#0`, `/re/`) is written, so a missed target is
  never written at the old one.

Addresses are sam's: `#n`, a line number, `/re/`, `?re?`, `-/re/`, `$`,
`.`, `0`, ranges `a,b` and `a;b`, `+`/`-`. They are evaluated from the
current address (the last written to `addr`, or just past the last `data`
write), not from the selection. pardes adds `12:5`: line 12, **byte**
column 5 from 1, clamped to the line's end (`12:0` is `address out of range:
a column counts from 1`); it composes (`12:5,14:1`). A tool's character
column matches only on an ASCII line; elsewhere use `12/name/` or `#n`. A
row of Recent, `+Search` or the Jumplist spells a range `12:5-14:2` (through
14:2 inclusive) and `addr` takes it as such.

Offsets and counts are bytes everywhere (acme counts runes), but every
address lands on a rune boundary: `#n` or `L:C` inside a rune snaps to its
start, a match covers the runes it touches, and a combining mark or a CRLF's
`\r` is addressable alone.

Refusals: `bad address syntax`, `no match for regexp`, `address out of
range`, `addresses out of order` (`#100,#50`), `bad regular expression`.

sam details kept: `$-1` is the last line when the text ends in a newline,
else the one before; with a final newline the empty place after it is a line
(`1` of an empty text is `#0,#0`, so `Edit 1i/x/` works on an empty file);
`2,1` is an empty range at line 2's start; `/^/` finds the empty place after
a final newline; a pattern that can match empty (`^`) passes over the match
where the search starts.

### Regular expressions

Patterns are [mvzr](https://github.com/mnemnion/mvzr)'s (classes, `\d\w\s`,
`{m,n}`, lazy `*?`), searched as sam searches, line by line: `^` and `$`
match at any line's start and end, `.` and `[^...]` never match a newline.
The same code (`src/regexp.zig`) serves addresses, Edit, and normal mode's
`s` and `S`. The ceiling:

- The leftmost match wins, but among alternatives the first that matches,
  not sam's longest (`/gam|gamma/` finds `gam`). mvzr keeps no
  submatches, so Edit's `s` has no `\1`-`\9`.
- A pattern holding `\n` runs over the whole text: there `^` may only come
  first and `$` only just before a `\n`, else it is refused.
- An alternation must anchor every branch with `^` or none (`^def|^ `
  works, `^def|x` is refused: `an alternation anchors every branch with ^
  or none`). A `$` does not count: `foo$|bar` is fine.
- A class may hold non-ASCII runes (`[éa-z]`, a range up to 256 runes); a
  wider range or a negated class with one (`[^é]`) is refused.
- At most 512 operations (about 512 characters, counted after that
  rewriting): `bad regular expression: longer than mvzr's 512 operations
  (about 512 pattern characters)`.
- Each search has a step budget (about 300 ms; each search of an Edit `x`
  its own): `regular expression search took too much time, gave up`. What
  runs out is exponential backtracking (`a?` twenty times then twenty `a`s)
  or a quadratic pattern over a very long line.

### Edit

`Edit <sam commands>` on a pane's `ctl` or `exec` (or the root's, at the
active pane) runs acme's Edit on the body: addresses as above, commands `x y
g v c a i d s p = m t u` and `{ }`. All changes are one undo step, applied
only if every command succeeds; a failure changes nothing and fails the write
with acme's words (`Edit: no substitution`). An `x` that finds nothing
succeeds silently. `p` and `=` print to the directory's `+Errors`. Not
there: `b B D e r w f X Y`, `< | >`, `\1`-`\9`. In `s`, `&` is the match
(`\&` a literal); in `c`, `a`, `i` it is a literal. `y` yields the stretch
before the first match too.

Braces take a command a line, so a block goes on one open, in one write or
several:

```sh
printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl
```

### Flags and undo

`dirty`, `mark` and `scroll` read and take `0` or `1`: the buffer differs
from its file (a file deleted on disk counts); a write pushes an undo point
(writing `1` pushes one now); a write scrolls the pane. `/index`'s dirty
flag is `dirty`; a `+New` scratch reads 1 but holds up nothing under 100
bytes.

The writes of one open of `data`, `xdata` or `body` are one undo step (so a
multi-line `printf` is one), as long as no other client or keystroke edits
the pane between them: such an edit ends the step, and the open's next
write starts another. To make a loop of opens one step: `echo 1 >
mark` (a point now), `echo 0 > mark`, the writes, `echo 1 > mark`.

### event

Holding `event` open takes the pane's Look and Exec clicks: they come to the
reader as records instead of acting, as do lines written to the pane's own
`look`/`exec` (or the root's while it has the keyboard). A record is acme's
`<origin><action><q0> <q1> <flag> <n> <text>\n`; read `n` **bytes** of
text, which may hold newlines. One open reads a pane's `event` at a time,
as acme's is one window's: a second open for reading fails `file in use`
(EBUSY) until the first is closed.

- origin: `E` a 9P write to body or tag, `F` other files and the editor's
  own lines, `K` keyboard, `M` mouse.
- action: `X`/`L` executed/looked in the body, `x`/`l` in the tag (offsets
  into the whole tag, path included), `I`/`D` body text inserted/deleted,
  `i`/`d` the tag's.
- flag: 1 the text's first word is a builtin, 4 (look) a file name or
  address, 8 (exec) chorded: two records follow, the argument and where it
  came from. pardes never sends flag 2.
- A written line, or a click in a terminal's body, has no place: `0 0` with
  its text (`FX0 0 1 6 Msg hi`).

Write a record back to have it done as the click would: `<o><a><q0> <q1>\n`
acts on that range (the last record of a write needs no newline), and an
empty one (`MX12 12`) on the word or file name a click there expands to;
the whole record as read acts on its text (the only way for `0 0`). A chorded record with its two follow-ups, in one write or
three, runs once with its argument. `I`, `D`, `i`, `d` are refused. A helper
holding `event` that writes its own pane's `exec` gets its command back as a
record: run it through `ctl` instead.

### REPLs

`Repl python` on a terminal's `ctl` (or in its tag) binds it as that
language's REPL (names as a code fence spells them: `py`, `sh`, ...); its
tag and `ctl` line show its id, `python-a`. `Repl -` unbinds, bare `Repl`
says the binding. A middle click or the execute key on a `.py` body then
types the text into the REPL instead of running it; builtin words, tag
words, `Exec <text>` and @`cmd` words still run. With several bound, the
pane asks (`ask <serial> repl a b`). Bindings are not dumped.

A 9P `exec` is never sent to a REPL. A script either writes the event record
`MX<q0> <q1>` to the `.py` pane's `event` (sent as the click would be), or
writes the REPL's `pty/data` itself: multi-line code as a bracketed paste,
`\e[200~<code>\e[201~`, then `\r` in a separate write once the REPL has
echoed the paste; a paste of more than one line not ending in a newline needs
a second `\r`. Line by line, a blank line ends a Python block and Python
3.14 auto-indents each line.

## Terminals

Terminal panes also have `pty/`:

- `pty/data`: write bytes as typed (`printf 'ls\r'`, `\x03` is Ctrl-C);
  read the live output stream (a consuming queue shared by readers, not a
  replay).
- `pty/status`: one line, `cols rows busy`; busy is 1 while a command runs or
  text is typed at the prompt.
- `pty/ctl`: `winsize C R` (at least 2 rows, fewer refused `invalid winsize: at least 2 rows`; at most 4096 a side), `sig INT|TERM|HUP|QUIT|KILL`,
  `exec` (restart the shell in its directory: refused on a command pane, `a
  command pane does not restart`; `exec: <dir>: no such directory` if it is
  gone; a shell that cannot start fails and leaves the old one running).
- `pty/run`: one line at the shell's prompt, answered on the same open:

  ```sh
  exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3; exec 3<&-
  ```

  The answer's first line is the header: `exit N` then the command's output
  (as the screen showed it: no colour, `\r` progress collapsed, trailing
  blanks dropped; the last 64 KiB, `exit N cut M` when M bytes were left
  out, bare `cut` when the start scrolled away or was cleared). Or: `busy:
  <program> is running` (bare `busy` when text is typed at the prompt),
  `exit ?` (no status reported, not a success), `error not run` (the shell
  refused the line, e.g. a fish syntax error), `error shell gone`, `error no
  prompt marks`, and on a command pane `error a command runs here, not a
  shell` (`error command done; not a shell` once it ended). A line written
  before a fresh terminal's first prompt waits for it. One line per run;
  a second line before the answer is refused.

`pty/run` relies on the OSC 133 marks pardes injects into bash and fish;
`exec zsh` or a continuation prompt never reports an end, so cancel the
read. A program on the alternate screen (vim, less) leaves no output. A
program holding the terminal (a REPL, `less`) takes no run: write to
`pty/data`.

## /log

One 64 KiB ring, one record a line, kept whether or not anyone reads it. An
open freezes it and reads to EOF. Write `follow` to that open to then wait
for each new record (`follow new` skips the history, as `tail -n0 -f`);
`tail -f` never writes `follow`, so it sees nothing new.

```sh
exec 3<>$m/log; echo follow >&3; while read -r line <&3; do ...; done
```

A follower the ring outran reads `lost N` first. A Restore hangs the
follower up: dial again and read from `restore <path>`.

| record | when |
|---|---|
| `new <serial> <name>`, `del`, `rename`, `save` | a pane made, closed, renamed (a terminal's too, as its shell changes directory), saved (`Save path` of a copy: `save <serial> <path>`, the path written) |
| `newcol <serial>`, `delcol <serial>` | a column made or closed |
| `msg <serial\|-> <text>` | the editor said something (not a builtin's own name under `Verbose`) |
| `err <serial\|-> <file>: <why>` | a write was refused or failed |
| `run <serial> <line>`, `exit <serial> <N\|?>` | a command pane's command started and ended; also a terminal whose shell exited, before its `del` |
| `send <from> <to> <repl-id>` | text went to a REPL |
| `ask <serial> <what> <choices>`, `answer <serial> <choice\|->` | a pane asked, and was answered (`-`: taken back, or the pane closed) |
| `changed <serial> [reloaded\|deleted]` | its file changed on disk (bare: under unsaved edits) |
| `unsaved <serial> <name>` | a pane an Exit, Restore, Del or Delcol refused over, before the `err` |
| `dump <path>`, `restore <path>` | a Dump written; a Restore, after its panes' `new`s |
| `restored <old> <new>`, `restoredcol <old> <new>` | serial maps after a Restore |

The serial is the pane the line ran at (the active pane for the root's
`look`/`exec`), `-` for the root `ctl`, `/tagexec` and column files. A
record said again straight after itself is counted, `err 3 addr: no match
for regexp (x4)`; a follower sees each count as a new line. A `msg` is cut
at 256 bytes and an `err` reason at 200, ending in `…`. Control characters
become spaces.

## Other files

- `/screen`: JSON `cols`, `rows`, `cursor`, `styles`, and row-major `cells`
  of `[grapheme, style_index]`; one frame per open. Compare a cell's style
  through `styles`, not the index.
- `/commands`: `Word [arg] root|pane|both [values] -- sentence`, one a line,
  generated from the builtin registry (`both`: Edit, at the active pane from
  the root).
- `/recent`: up to 200 files, PDFs and images, most recent first, `open
  <path>` or `closed <path>`; kept in `$XDG_STATE_HOME/pardes/recent`.
  `Recent` shows them in a pane, an open one at its dot now and a closed
  one at its last (a PDF's is its page); a look at a row reopens it there.
- `/status`: `pid`, `version`, `panes`.
- `/os/`: existing regular files take read, write and truncation to zero;
  create, remove, rename and mode changes are refused as not permitted
  (`permission denied`, EACCES through a mount); ownership is
  synthetic. Linux v9fs's truncation `mtime` hint is accepted and dropped.
- `/src/` (and `/shaders` on GUI builds) with `-Dembed-sources=true`;
  `EffectCode <effect>` lists an effect's files under `/virtual`.

## Limits

| | |
|---|---|
| panes | 64 (16 on the board) |
| columns | 16 (6 on the board), each at least 10 cells wide |
| rows a pane keeps | its tag and 2 |
| msize | 65536 (64 KiB) offered |
| connections | 16 Unix+TCP, 16 QUIC |
| opens holding state | 64 |
| named mounts | 8 |
| command line | 1024 bytes; a held line or Edit block 1 MiB |
| tag text | 4096 bytes |
| regular expression | 512 operations; a step budget per search (~300 ms) |
| undo | 256 steps |
| log | 64 KiB ring; `msg` 256 bytes, `err` reason 200 |
| `pty/run` output | 64 KiB |
| file name component | 255 bytes |

## Source and tests

`src/ninep/`: `tree.zig` (nodes, dispatch), `pane.zig`, `ctl.zig`,
`cols.zig`, `addr.zig`, `pty.zig`, `events.zig` (event and log), `screen.zig`,
`sources.zig`. The engine is cloud9's `fs.Server` (`src/9p.zig`); transports
are in `src/9p_io.zig`; `src/fs.zig` holds host access, mounts and
resolution. `zig build fs-test` drives real sessions with `test/ninep.py`;
`zig build 9p-test` checks the engine budgets.