- commit
- ef7b6e956733fc0f645c7c46415a36739c30d9cd
- parent
- cac049458be88b3dcd336aadb2df18b4dc109ca6
- parent
- 0dc423758846154e576c0da46653b544f13cc1fa
- author
- Dami <dami.etoile@gmail.com>
@@ -69,6 +69,10 @@ rest: building from a clone, importing files, `012 serve` and settings.
6969 SSH, your terminal's colors or any of hundreds of schemes, optional vim7070 keys, and `012 serve` to reach your sheets over SSH7171 ([keys](docs/reference/keys.md), [themes](docs/terminal/themes.md), [SSH](docs/terminal/ssh.md)).72+- **Together over SSH**: sessions of `012 serve` opening the same file73+ share it live, each with its own cursor, the others' in their colors,74+ and undo that takes back only your own changes75+ ([sharing a workbook](docs/terminal/ssh.md#sharing-a-workbook)).7276 - **JEV functions** ask TypeSafe's hosted model about your data from a7377 formula ([JEV](docs/formulas/jev.md)).7478 @@ -36,25 +36,13 @@ completed by nu as they're written.
3636 |---|---|---|3737 | A command's signature and description for the word at the caret, on the context line, through `nu --ide-hover` or `nu --lsp` | Writing a cell without leaving for `help` | S |3838 39-### 3. Toward multiplayer40-41-Sessions share one workbook through the server that already hosts them42-(`012 serve`), so every change is ordered in one place: the workbook's one43-mutation path (`Batch`/`Change`) becomes a stream of operations, the same44-log macros record. Each step is useful on its own.45-46-| Step | Result | Size |47-|---|---|---|48-| A notebook cell's output follows a streaming pipeline through the linked regions' live sources (`live.Source`), rows arriving on its sheet as the pipeline writes them | Pipelines as live sheets | S |49-| Shared viewing over SSH: several `012 serve` sessions open the same workbook; one edits, the others follow live with their own cursor, scroll and theme; presence shows who is where | Watch-along and review, one writer | M |50-| Shared editing over SSH: every session edits, the server orders operations (no CRDT needed while one server holds the workbook), per-user undo, presence and edit ownership shown in the grid, saves by the server | Multiplayer 012 over SSH | L |51-52-### 4. Agents39+### 3. Agents5340 5441 Agents work on workbooks through the same operations people do (the5542 `Batch`/`Change` path), so their edits are undoable, attributed and5643 shown by `012 diff`, under the same trust rules as macros. Live mode is57-section 3's shared editing with an agent as one participant.44+`012 serve`'s shared editing with an agent as one participant45+([Architecture](docs/contributing/architecture.md#shared-workbooks)).5846 5947 | Step | Result | Size |6048 |---|---|---|@@ -67,8 +55,9 @@ tickets (the Go transport in `FelineStateMachine/allons`
6755 `local/transport/iroh`, which needs cgo and a prebuilt iroh-ffi archive, so6856 it would sit behind a build tag) and the web (`NimbleMarkets/go-booba`6957 serves Bubble Tea over WebSocket/WebTransport with ghostty-web; Bubble Tea70-v2 support unverified). They would reuse the server-ordered operation71-stream above rather than a design of their own.58+v2 support unverified). They would reuse the rooms of `012 serve`, whose59+server orders every participant's operations, rather than a design of60+their own.7261 7362 ## Shipped7463 @@ -121,6 +110,8 @@ stream above rather than a design of their own.
121110 - Color schemes and a config file: [Themes](docs/terminal/themes.md), [Configuration](docs/reference/config.md)122111 - A high-contrast theme at WCAG AAA, and every state readable without color: [Themes](docs/terminal/themes.md#high-contrast), [UX](docs/contributing/ux.md#reading-without-color)123112 - `012 serve` over SSH, with files on the ssh command line and recovery of unsaved work: [Serving over SSH](docs/terminal/ssh.md)113+- Shared viewing over SSH: sessions opening one file share it live, each with its own cursor, the others' pointers and names shown, one writer handing writing over: [Serving over SSH](docs/terminal/ssh.md#sharing-a-workbook)114+- Shared editing over SSH: everyone edits, the server orders every step, undo takes back only your own, changes by others marked, saves and notebook runs by the room: [Serving over SSH](docs/terminal/ssh.md#sharing-a-workbook)124115 - Shift+Enter, Ctrl+I and keys held to preview, with the kitty keyboard protocol: [Keys and mouse](docs/reference/keys.md#keys-the-terminal-has-to-tell-apart)125116 - Sixel chart images on terminals without kitty graphics, drawn after the frame and redrawn as the screen moves: [Charts](docs/sheets/charts.md)126117 @@ -129,6 +120,7 @@ stream above rather than a design of their own.
129120 - 012 as a stage in a pipeline: `012 -` reads a table from standard input, `012 --pipe` sends the sheet or selection on, with nushell's types kept through NUON: [Pipelines](docs/nushell/pipelines.md)130121 - The `sheet` command: a nushell module shipped in the binary (`012 nu --install-module`), so nu calls 012 without `^012` or NUON on either side: [Pipelines](docs/nushell/pipelines.md#the-sheet-command)131122 - Notebook tabs, a TUI Jupyter for nushell: code and note cells with Jupyter's keys, outputs drawn in place and opened full-screen, `$name` between cells, stale marks and a reactive mode, outputs sent to sheets as live regions (`nu.name`), saved up to a cap, and earlier notebook sheets converted on open: [Notebooks](docs/nushell/notebooks.md)123+- Cells run as streams: a pipeline that never ends (`tail -f`, `watch`) followed live, its rows reaching the output and its sheet as nu prints them: [Notebooks](docs/nushell/notebooks.md#streams)132124 - Code cells highlighted, checked and completed as they're written by nu itself (`--ide-ast`, `--ide-check`, `--ide-complete`), in the background, falling back to 012's own when nu is missing, old, slow or not trusted: [Notebooks](docs/nushell/notebooks.md#writing-a-cell)133125 - Table and record outputs drawn and worked as 012's own grid: formats by type, fitted and resizable columns, select, copy, sort, filter and find in place, full-screen, charts and pivots on the sheet the output is sent to: [Notebooks](docs/nushell/notebooks.md#outputs-as-grids)134126 @@ -19,7 +19,7 @@ import (
1919 "github.com/FelineStateMachine/012/internal/serve"2020 )2121 22-const serveUsage = "usage: 012 serve [--listen addr] [--authorized-keys file] [--host-key file] [--idle-timeout 30m] [--max-sessions 8] [dir]"22+const serveUsage = "usage: 012 serve [--listen addr] [--authorized-keys file] [--host-key file] [--idle-timeout 30m] [--max-sessions 8] [--share edit|view|off] [dir]"2323 2424 // serveOptions are 012 serve's settings from the config file, the2525 // serve-* options, over the built-in defaults.@@ -32,6 +32,7 @@ func serveOptions(c *config.Config) serve.Options {
3232 }3333 o.IdleTimeout = c.Duration("serve-idle-timeout")3434 o.MaxSessions = c.Int("serve-max-sessions")35+ o.Share = c.String("serve-share")3536 return o3637 }3738 @@ -49,6 +50,7 @@ func serveFlags(args []string, stderr io.Writer, o serve.Options) (serve.Options
4950 fs.StringVar(&o.HostKey, "host-key", o.HostKey, "the server's private key, generated if missing")5051 fs.DurationVar(&o.IdleTimeout, "idle-timeout", o.IdleTimeout, "end sessions without input for this long (0: never)")5152 fs.IntVar(&o.MaxSessions, "max-sessions", o.MaxSessions, "most sessions at once")53+ fs.StringVar(&o.Share, "share", o.Share, "sessions opening the same file share it: edit, view (one writer) or off")5254 var dirs []string5355 for {5456 if err := fs.Parse(args); err != nil {@@ -14,8 +14,8 @@ func TestServeFlags(t *testing.T) {
1414 t.Errorf("no flags: %+v, %v; want the defaults", o, err)1515 }1616 o, err = serveFlags([]string{"--listen", "127.0.0.1:9000", "sheets", "-idle-timeout=5m", "--max-sessions", "3",17- "--authorized-keys", "/k", "--host-key=/h"}, io.Discard, serve.Defaults())18- want := serve.Options{Dir: "sheets", Listen: "127.0.0.1:9000", AuthorizedKeys: "/k", HostKey: "/h", IdleTimeout: 5 * time.Minute, MaxSessions: 3}17+ "--authorized-keys", "/k", "--host-key=/h", "--share", "view"}, io.Discard, serve.Defaults())18+ want := serve.Options{Dir: "sheets", Listen: "127.0.0.1:9000", AuthorizedKeys: "/k", HostKey: "/h", IdleTimeout: 5 * time.Minute, MaxSessions: 3, Share: "view"}1919 if err != nil || o != want {2020 t.Errorf("got %+v, %v; want %+v", o, err, want)2121 }@@ -0,0 +1,33 @@
1+2+# share-demo: two people in one workbook over 012 serve, as the share3+# tape records them: a server on a budget in out/share/served, reached4+# with ssh as ann in the top tmux pane and as bob in the bottom one.5+# `share-demo stop` ends the server. Runs from demos/.6+set -e7+d=out/share8+port=23999+if [ "$1" = stop ]; then10+ tmux -L 012demo kill-server 2>/dev/null || true11+ kill "$(cat $d/serve.pid)" 2>/dev/null || true12+ exit 013+fi14+rm -rf $d15+mkdir -p $d/served16+ssh-keygen -q -t ed25519 -N '' -C demo -f $d/id17+cp $d/id.pub $d/authorized_keys18+chmod 600 $d/authorized_keys19+cat > $d/served/budget.012 <<'EOF'20+{"version": 2, "cells": {"A1": "Household budget", "A3": "Rent", "B3": "1200", "A4": "Food", "B4": "450",21+ "A5": "Power", "B5": "90", "A6": "Total", "B6": "=SUM(B3:B5)"}}22+EOF23+../bin/012 serve --listen 127.0.0.1:$port --authorized-keys $d/authorized_keys --host-key $d/host_key $d/served 2>$d/serve.log &24+echo $! >$d/serve.pid25+until grep -q "on 127.0.0.1:$port" $d/serve.log 2>/dev/null; do sleep 0.1; done26+echo "[127.0.0.1]:$port $(cat $d/host_key.pub)" >$d/known_hosts27+ssh="ssh -F /dev/null -i $d/id -o IdentitiesOnly=yes -o IdentityAgent=none -o UserKnownHostsFile=$d/known_hosts -o LogLevel=ERROR -p $port -t"28+tmux -L 012demo -f /dev/null new-session -d -s share -x "$(tput cols)" -y "$(tput lines)" "$ssh -l ann 127.0.0.1 budget.012"29+tmux -L 012demo set -g status off30+tmux -L 012demo set -g pane-border-style fg=brightblack31+tmux -L 012demo set -g pane-active-border-style fg=cyan32+tmux -L 012demo split-window -v "sleep 1; $ssh -l bob 127.0.0.1 budget.012"33+exec tmux -L 012demo attach -t share@@ -0,0 +1,59 @@
1+# Sharing a workbook over 012 serve: ann (top pane) and bob (bottom)2+# open the same budget through ssh, in tmux panes (Ctrl+B and an arrow3+# moves between them). Bob changes the food line, ann the rent: each4+# sees the other's change arrive, their pointer in its color and their5+# initial on the row header. Ann lists who's here; bob's undo takes6+# back only his own change. Needs tmux, ssh and ssh-keygen.7+Output out/share.gif8+Source lib/setup.tape9+Set Width 130010+Set Height 90011+12+Hide13+Type "clear; lib/share-demo" Enter14+Sleep 3s15+Show16+17+Sleep 800ms18+Down19+Down20+Down21+Right22+Type "480"23+Enter24+Sleep 600ms25+Right26+Right27+Sleep 800ms28+Ctrl+B29+Up30+Sleep 500ms31+Down32+Down33+Right34+Type "1300"35+Enter36+Sleep 1.5s37+Screenshot out/stills/share-presence.png38+Ctrl+K39+Type@100ms "who's here"40+Sleep 500ms41+Enter42+Sleep 2s43+Escape44+Sleep 500ms45+Ctrl+B46+Down47+Sleep 500ms48+Ctrl+Z49+Sleep 2s50+Screenshot out/stills/share-undo.png51+Sleep 1s52+53+Hide54+Ctrl+B55+Type ":kill-server"56+Enter57+Sleep 500ms58+Type "lib/share-demo stop" Enter59+Sleep 500ms@@ -31,6 +31,7 @@ internal/nushell a notebook cell's run: nu as a process, tables in as NUON files
3131 module 012.nu, the nushell module with sheet, embedded for 012 nu --module and --install-module3232 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats3333 internal/serve the SSH server: auth, host key, a Model per session (charm.land/wish/v2)34+internal/room shared workbooks: a room per file, a seat per participant, taking turns on the one workbook3435 internal/confine resolving typed file names, confined to a directory when served3536 internal/stress synthetic worst-case sheets for the -tags stress benchmarks3637 internal/ui the Bubble Tea model: modes, menus, overlays, rendering@@ -71,7 +72,7 @@ flowchart TD
7172 direction LR7273 ui[internal/ui] ~~~ fileio[internal/fileio] ~~~ live[internal/live] ~~~ nushell[internal/nushell]7374 chart[internal/chart] ~~~ jev[internal/jev] ~~~ stress[internal/stress] ~~~ oracle[oracle]74- headless[internal/headless] ~~~ diff[internal/diff]75+ headless[internal/headless] ~~~ diff[internal/diff] ~~~ room[internal/room]7576 end7677 callers --> sheet[internal/sheet]7778 sheet --> functions[internal/functions]@@ -290,12 +291,14 @@ style.
290291 291292 Rows reach a region as `sheet.LiveOp`s, each applied at once by292293 `Workbook.ApplyLive` through the regions' one write path, `writeTable`:293-what follows a linked file sends them, and the UI sends a notebook294-cell's output to the region it was sent to whenever the cell runs.294+what follows a linked file sends them, the UI sends a notebook cell's295+output to the region it was sent to whenever the cell runs, and a cell296+run as a stream sends each batch of rows its pipeline prints.295297 296298 ```mermaid297299 flowchart TD298300 file[a linked file] -->|polled| source[live.Source]301+ stream[a cell run as a stream: nu printing a value a line] -->|live.Stream| source299302 source -->|live.Update| op[sheet.LiveOp]300303 cell[a notebook cell's run] -->|its output, fileio.NUONRows| op301304 op --> apply[Workbook.ApplyLive]@@ -318,9 +321,8 @@ window drops the oldest rows (read back from the region's cells and
318321 written again one row up); inside an open step (a macro run) what reads319322 the cells recalculates when the step ends. An op carries whole320323 values and names nothing but its region, so applying the same ops in the321-same order to the same workbook makes the same cells: it is what a322-session following another's over `012 serve` would be sent, beside the323-steps `Batch` records. `OnLive` times each op for telemetry.324+same order to the same workbook makes the same cells, whoever applies325+it. `OnLive` times each op for telemetry.324326 325327 ### Functions326328 @@ -612,8 +614,10 @@ file followed as it grows: the importers' readers run on a goroutine of
612614 its own over a pipe that waits for the next piece, so a record cut off613615 at the end of one waits for the rest, and rows come out typed as an614616 import types them (`tail.go`). A `live.Source` is anything that yields a615-table's rows over time, which a notebook cell following a pipeline616-would be too.617+table's rows over time: a followed file (`live.File`), or a notebook618+cell run as a stream (`live.Stream`), whose pipeline nu runs printing619+each value as NUON on a line of its own (`nushell.Stream`), read by the620+same `Tail` as it arrives.617621 618622 XLSX is read by 012's own SpreadsheetML reader on `archive/zip` and619623 `encoding/xml`: the workbook, shared strings (kept end to end in one@@ -790,11 +794,88 @@ sequenceDiagram
790794 791795 Each session runs its `tea.Program` over wish's emulated PTY; the792796 client's environment is what the model detects the terminal from, and793-its `confine.Root` resolves every file name. Everything a model knows lives in the model, so sessions794-share nothing but read-only tables (the command and function795-registries), the process-wide telemetry and, with JEV on, the HTTP796-client; each gets its own `jev.Cache`, whose queue belongs to that797-session's program. See [Serving over SSH](../terminal/ssh.md).797+its `confine.Root` resolves every file name. Everything a model knows798+lives in the model, so sessions share nothing but read-only tables (the799+command and function registries), the process-wide telemetry, with JEV800+on the HTTP client (each gets its own `jev.Cache`, whose queue belongs801+to that session's program), and the workbooks of the rooms they're in802+(below). See [Serving over SSH](../terminal/ssh.md).803+804+### Shared workbooks805+806+Sessions opening the same file (its path on disk is the key) or the807+same named room share one `*sheet.Workbook` (`internal/room`). There is808+nothing to merge: the server holds the workbook, and participants take809+turns on it. A `room.Registry` holds the rooms; `Join` seats a810+participant, opening the room on the file's workbook when nobody has811+it open, and the last `Leave` closes it. A seat's `Do` is a turn: the812+room's lock held, the workbook's author set to the seat, so every step813+made is attributed; when the turn ends, the room notes the operations814+it made and tells the others. A served session runs its model under815+`ui.Shared`, which makes every `Init`, `Update` and `View` a turn,816+publishes where the user is (`Seat.Move`), and between turns joins and817+leaves rooms, never holding two locks. An operation's way from one818+session's key to another's screen:819+820+```mermaid821+sequenceDiagram822+ participant A as session A (ui.Shared)823+ participant R as the room (room.Seat.Do)824+ participant W as the workbook825+ participant B as session B826+ A->>R: a key: Update, a turn827+ R->>W: SetAuthor(A's seat)828+ A->>W: Sheet.Set, Batch: a step, recalculated829+ W->>W: the step pushed: author, operation number (sheet.Op)830+ R->>R: turn ends: the new operations marked (who, which cells)831+ R-->>B: Participant.Notify832+ B->>R: roomMsg: Update, a turn833+ B->>R: the marks since it last looked, the others' presence834+ B->>B: View in its turn: the change, A's pointer in A's color835+```836+837+Undo in a shared history takes back the author's own latest step,838+from under others' later steps when none of them changed what it839+changed, and refuses otherwise, naming whose step is in the way840+(`sheet/authors.go`, where the rule is written down); cancelling a841+dialog undoes the user's own steps since it opened (`Checkpoint`,842+`UndoTo`). `TestRandomSharedEdits` holds it to undoing, in any order843+it allows, back to the start. A step left open across turns (a macro844+run, `Workbook.Begin`) holds the others' input until it ends, and one845+left open by a session that crashed is closed (`EndStep`).846+847+What the room runs is the room's: linked files are followed by the848+seat there longest (`Seat.Keeper`), notebook cells' runs and streams849+(`nbRuns`) are a value the room keeps (`Seat.Value`), and what their850+commands report goes to whoever keeps the room then (`Seat.Post`,851+`Take`), so a run outlasts the session that started it. The room's852+last save (`Seat.SetSaved`) is what every session counts unsaved853+changes from, and the last session out keeps them for recovery.854+855+A participant is anything with a name that can be told the room856+changed; everything else it does, it does through its seat:857+858+| `room.Participant` | What it is |859+|---|---|860+| `Name()` | who they are to the others: the SSH user |861+| `Notify()` | the room changed; called with the room locked, so it only schedules a turn (a session sends its program a `roomMsg`) |862+863+| `room.Seat` | What it does |864+|---|---|865+| `Do(fn)` | a turn: `fn` on the workbook, the lock held, steps in the seat's name |866+| `Move`, `Peers`, `Marks`, `Last` | where this participant is; the others and where they are; who changed which cells since an operation |867+| `Writing`, `HandTo`, `Writer` | the one-writer mode's role |868+| `Saved`, `SetSaved`, `Value`, `Keeper`, `Post`, `Take` | what the room shares: its file's saved state, values such as the notebook runs, and messages for whoever keeps it |869+| `Leave` | the seat given up; reports whether the room closed |870+871+The interface stays this small so an MCP client attached to the872+server can be a participant beside sessions (ROADMAP.md, Agents):873+`internal/mcp`'s tools stand on a `Backend` of `View`, `Scratch` and874+`Change`, and a room's would be a seat, `View` and `Change` its turns875+(the change one `Batch`, attributed to the client's seat, so its own876+undo and the others' marks follow), `Scratch` a copy of the workbook877+taken in a turn, and `Notify` what tells the client the workbook878+changed.798879 799880 **Unsaved work.** `Server.Shutdown` closes a channel every session800881 watches; a session quitting on it, or on its idle timeout, asks its@@ -111,7 +111,8 @@ the same way.
111111 Every change to a workbook goes through `Batch(Change{...}, fn)` and the112112 history `step`: snapshot what changes, apply, mark dirty, recalculate. Undo,113113 telemetry, recalculation and the file's dirty flag hang off that one path,114-and so will anything later that needs to see every change (live sharing).114+and the rooms of `012 serve`, which order everyone's steps and tell each115+participant what the others changed ([Architecture](architecture.md#shared-workbooks)).115116 New mutations use it; they never write cells around it. A change made over116117 several calls, as a macro run makes one call per message, opens its step117118 with `Workbook.Begin` and closes it when done, so it undoes as one; inside@@ -488,7 +488,8 @@ read-heavy recalculation.
488488 489489 `BenchmarkSessions` (`internal/serve/stress_test.go`) opens N sessions,490490 each over its own SSH connection on loopback at 120 x 40, optionally491-opening the same 1000 x 26 sheet of numbers in every one, then has every491+opening the same 1000 x 26 sheet of numbers in every one, a copy each492+(`--share off`) or shared in one room, then has every492493 session press an arrow key at once, 200 times, timing each key from the493494 client's write to the first bytes of its frame arriving. The clients494495 run in the same process, so the heap and CPU figures include their side@@ -501,6 +502,8 @@ for 120 frames a second, as the local app does (`-benchtime 200x`):
501502 | 10, 1000 x 26 numbers | 1.9 MiB | 3.9 / 8.3 / 10.8 ms | 0.79 ms |502503 | 50, new sheet | 1.30 MiB | 5.0 / 8.7 / 17.8 ms | 0.53 ms |503504 | 50, 1000 x 26 numbers | 1.9 MiB | 5.1 / 8.9 / 12.8 ms | 0.66 ms |505+| 10, 1000 x 26 numbers, shared | 1.5 MiB | 5.0 / 8.3 / 16.6 ms | 1.27 ms |506+| 50, 1000 x 26 numbers, shared | 1.5 MiB | 7.1 / 15.8 / 33.2 ms | 0.79 ms |504507 505508 Latency doesn't move from 10 to 50 sessions: it's Bubble Tea's frame506509 pacing (at most one frame every 8.3 ms, so a key waits half a frame on@@ -517,6 +520,17 @@ second would keep about four cores busy: frames are drawn only when a
517520 session's screen changes, so the rate is the most keys can cause, not a518521 cost of being connected.519522 523+Sessions sharing a workbook hold it once, so each holds only its524+screen. They take turns on it: each key is handled under the room's525+lock, and moves a pointer every other session draws, so a key costs a526+frame in every session of the room (CPU per frame counts the pressing527+session's alone), and fifty sessions pressing keys in the same528+millisecond queue for the lock, the last waiting about two frames. A529+change reaching the others is quick next to the frame: from the end of530+one session's turn to another's frame showing it takes about 0.1 ms531+(`TestSharedEditWithinAFrame`), so it goes out with the other's next532+frame.533+520534 ## Pivot tables521535 522536 A pivot table is recomputed whole whenever a cell of its source range is@@ -45,9 +45,22 @@ failure names the fewest of the seed's edits that still fail. It takes
4545 about a second; `-randedit.seeds=20000` runs more, and4646 `FuzzRandomEdits` (in `make fuzz`) lets the fuzzer choose the edits.4747 48+`TestRandomSharedEdits` (`randedit_shared_test.go`) makes the same49+edits as three authors taking turns in one workbook with its history50+shared, as a room of `012 serve` holds it, then has them undo their own51+steps in turns drawn from the seed, out of order wherever the rule52+allows: someone can always undo, an undo redone at once gives back the53+file it undid, and once every step is undone the workbook is back where54+it started.55+4856 The UI is tested by sending4957 Bubble Tea messages (keys, mouse, paste, window size) to the model and50-reading what `View` renders, without a terminal.58+reading what `View` renders, without a terminal. Sessions sharing a59+workbook are driven the same way through `ui.Shared`, each room's60+changes handed to the others as their programs would get them61+(`share_test.go`), and in real Bubble Tea programs typing at once under62+the race detector, with the time an edit takes to reach another63+session's frame (`sharelive_test.go`).5164 5265 ## End to end, through libghostty5366 @@ -72,7 +85,10 @@ restores the terminal and keeps the work; release builds have no such key.
7285 7386 `e2e/ssh_test.go` runs `012 serve` and reaches it with the system's7487 `ssh` client inside the same libghostty terminal, so the server path75-is tested as a user sees it (skipped when `ssh` isn't installed).88+is tested as a user sees it (skipped when `ssh` isn't installed). `e2e/share_test.go` has two people89+reach one `012 serve` that way, each in a terminal of their own, and90+share a file; screens of a shared workbook start the others' sessions91+first (`screen.peers`).7692 7793 `make e2e` builds libghostty-vt from source with Zig into `.deps/` on first7894 use. It is its own Go module so cgo never reaches the main binary.@@ -112,6 +112,7 @@ there.
112112 | Pivot table results | Their headings (`SUM of Units`, `Grand Total`), and a note in words when an edit is refused |113113 | Protected ranges | A question in words before an edit (`A1:B2 is protected.`) |114114 | Notes | A `▝` in the cell's top-right corner |115+| Others in a shared workbook (012 serve) | Their pointer's cell double-underlined, their initial on its row's header, their name on the status line, `▘` in a cell they changed in the last 30 seconds, and `Changed by`, `is here` or their warnings in words on the context line |115116 | Macro recording | `REC` beside the mode indicator |116117 | Mode | The mode indicator's word (`READY`, `ENTER`, `POINT`, `MENU`, and on a notebook `NOTEBOOK`, `EDIT` or `OUTPUT`) |117118 | Search matches, traced cells | Reverse video, with a count or the list on the context line; dependents also bold |Binary file.
Binary file.
Binary file.
Binary file.
Binary file.
@@ -54,7 +54,7 @@ reads the same on a terminal without it:
5454 | Left of the cells | A bar at the active cell, blue in command mode and green in edit mode, and a thinner one at the others selected ([Keys](#keys)) |5555 | The box | Light lines, and heavy ones around the cell being edited |5656 | The prompt | How many runs came before this one: `[2]:`, `[*]:` while it runs, `[ ]:` before it has; `Out[2]:` marks the output of that run |57-| The box's top border | The cell's name at the left, and at the right how its run stands (below): `waiting`, `running`, ✓ and how long it took, `failed`, `stale`, or `saved` for an output read from the file |57+| The box's top border | The cell's name at the left, and at the right how its run stands (below): `waiting`, `running`, `● live` and the rows printed for a cell run as a [stream](#streams), ✓ and how long it took, `failed`, `stale`, or `saved` for an output read from the file |5858 | Right of the box | `▶` runs the cell, `■` stops it while it runs |5959 | The output | What it is ([below](#outputs)): a table or a record as a grid, its column names where a sheet has letters; `×` before an error; `output hidden`; which rows show in its window. Worked in, `OUTPUT` in the mode indicator, the bar left of it green, and its pointer and selection in reverse video, as on a sheet ([Outputs as grids](#outputs-as-grids)) |6060 | The context line | What the cell reads and how others read it (`big: reads $files; read as $big, nu.big in formulas`), the keys that apply, and what nu says about the cell being written, underlined curly in the cell ([Writing a cell](#writing-a-cell)) |@@ -229,7 +229,8 @@ delete, copy, cut, move, make notes or code, hide or clear outputs.
229229 | `o`, `O` | Hide the output, or show it again; show every row, or the window again |230230 | `G` | Send the output to a sheet |231231 | Ctrl+G | Go to a cell by its number, name, code or heading |232-| `ii` | Stop: kill the cell running, and forget those waiting |232+| `f` | Run the cell as a stream, until it's stopped ([Streams](#streams)) |233+| `ii` | Stop: kill the cell running, forget those waiting, and stop every stream |233234 | `00` | Restart: stop, clear every output, count runs from 1 |234235 235236 | Key | In edit mode |@@ -327,6 +328,31 @@ commands aren't there unless `nu-config` is on
327328 A cell stops after `nu-timeout` (30 seconds unless set); `ii` stops it328329 sooner.329330 331+### Streams332+333+A pipeline that never ends, such as following a log or `watch`ing a334+folder, runs as a stream: `f` (**Data > Shell > Run as stream**) runs335+the selected cell until you stop it, and every value it yields reaches336+the cell's output as soon as nu prints it, and the sheet the output was337+[sent to](#send-to-a-sheet) as rows under its header:338+339+```nu340+log = tail -f app.log | lines | parse '{time} {level} {msg}'341+changes = watch . --glob=*.csv --quiet342+```343+344+While it runs, the cell's box says `● live` with how many rows it has345+printed, and the toolbar how many cells stream; streams run beside the346+cells run once, each on its own, without `nu-timeout`. `ii` (or `■`)347+stops them all, keeping what they printed; running the cell once (`r`)348+stops its stream first. A stream that ends on its own keeps its output349+as a run does, and one that fails says why.350+351+The output keeps a stream's last 10,000 values; a sheet it was sent to352+keeps every row, up to `max-cells`. The rows arrive as the change353+stream does for a [followed file](../files/following.md), outside the354+undo history.355+330356 ## Names and $name331357 332358 A cell that starts `name =` gives its output that name; `n` names a@@ -91,6 +91,7 @@ start.
9191 | [`serve-host-key`](#serve-host-key) | | |9292 | [`serve-idle-timeout`](#serve-idle-timeout) | `30m` | |9393 | [`serve-max-sessions`](#serve-max-sessions) | `8` | |94+| [`serve-share`](#serve-share) | `edit` | |9495 | [`serve-shell`](#serve-shell) | `false` | |9596 | [`config-file`](#config-file) | | |9697 @@ -357,6 +358,16 @@ How many 012 serve sessions may run at once; more are turned away.
357358 | Default | `8` |358359 | Applies | restart 012 |359360 361+#### `serve-share`362+363+Whether 012 serve sessions opening the same file share its workbook: edit, everyone edits; view, one writes and the others follow until writing is handed over; off, each session has its own copy.364+365+| | |366+|---|---|367+| Type | one of `edit`, `view`, `off` |368+| Default | `edit` |369+| Applies | restart 012 |370+360371 #### `serve-shell`361372 362373 Let 012 serve sessions run notebooks' code cells, as the user 012 serve runs as, following the shell option. Off, served notebooks show their cells and saved outputs but run nothing.@@ -54,7 +54,7 @@ mouse and the vim keymap added.
5454 | Key | Action |5555 |---|---|5656 | Del, Backspace | Clear the selection; formatting stays |57-| Ctrl+Z; Ctrl+Y, Ctrl+Shift+Z | Undo; redo |57+| Ctrl+Z; Ctrl+Y, Ctrl+Shift+Z | Undo; redo. In a workbook shared over `012 serve`, your own changes only ([Sharing a workbook](../terminal/ssh.md#sharing-a-workbook)) |5858 | Ctrl+C, Ctrl+X, Ctrl+V | Copy, cut, paste; references adjust as in Sheets, and copies also go to the system clipboard |5959 | Ctrl+Shift+V | Paste values only |6060 | Paste in the terminal | Tab-separated or multi-line text fills a block of cells |@@ -128,6 +128,11 @@ keeping its cells.
128128 | Shift+F10, right-click | The cell, column or row menu |129129 | F1, Ctrl+/ | Keyboard shortcuts |130130 131+In a workbook shared over `012 serve`, **File > Who's here** (or `who` in the132+palette) lists the others and goes to one's cell, and **File > Hand over133+writing** passes writing on in a one-writer room; neither has a key of134+its own ([Sharing a workbook](../terminal/ssh.md#sharing-a-workbook)).135+131136 ## Macros132137 133138 | Key | Action |@@ -157,7 +162,8 @@ on a sheet, `!` starts an entry as any character does.
157162 | `o`, `O` | Hide the output, or show it again; show all of it rather than a window |158163 | `n`, `G` | Name the cell; send the output to a sheet |159164 | Ctrl+G | Go to a cell by its number, name, code or heading |160-| `ii`, `00` | Stop what's running; restart, clearing every output |165+| `f` | Run the cell as a stream, its rows arriving as the pipeline prints them |166+| `ii`, `00` | Stop what's running, streams too; restart, clearing every output |161167 162168 | Key | In edit mode |163169 |---|---|@@ -8,26 +8,29 @@ sidebar_position: 3
88 `012 serve` runs an SSH server that gives every session its own 012, in99 the client's own terminal, on the files of one directory. It's for1010 reaching your sheets from another machine or a tablet with an SSH app,11-without installing anything there. It is not shared editing: two12-sessions are two separate spreadsheets. Shared viewing and editing over13-`012 serve` are planned: see [toward multiplayer](../../ROADMAP.md#3-toward-multiplayer).11+without installing anything there, and for working on one workbook12+together: sessions that open the same file share it, each with its own13+cursor, and every change reaches the others as it's made14+([Sharing a workbook](#sharing-a-workbook)).1415 1516 ```sh1617 012 serve ~/sheets # serve ~/sheets on 127.0.0.1:23121718 ssh -p 2312 localhost # from this machine, on a new sheet1819 ssh -t -p 2312 localhost budget.012 # opening budget.012 from ~/sheets20+ssh -t -p 2312 localhost @standup # joining the named room standup1921 ```2022 2123 A file name after the host opens that file, as `012 budget.012` does2224 locally: a `.012` sheet, a new sheet to be saved under that name when23-there's no such file, or a file to import (`data.csv`). It must be one24-word (quote a name with spaces twice: `ssh -t host "'my book.012'"`)25-and needs `-t`, since ssh only asks for a terminal on its own when26-there's no command.25+there's no such file, or a file to import (`data.csv`). A name starting26+with `@` joins a named room instead. It must be one word (quote a name27+with spaces twice: `ssh -t host "'my book.012'"`) and needs `-t`, since28+ssh only asks for a terminal on its own when there's no command.2729 2830 ```2931 012 serve [--listen addr] [--authorized-keys file] [--host-key file]30- [--idle-timeout 30m] [--max-sessions 8] [--log file] [--otlp url] [dir]32+ [--idle-timeout 30m] [--max-sessions 8] [--share edit|view|off]33+ [--log file] [--otlp url] [dir]3134 ```3235 3336 | Flag | Default | What |@@ -38,6 +41,7 @@ there's no command.
3841 | `--host-key` | `<config dir>/012/ssh_host_ed25519_key` | The server's key, generated on first run |3942 | `--idle-timeout` | `30m` | End a session after this long without input, keeping its unsaved changes; `0` never does |4043 | `--max-sessions` | `8` | Sessions at once; more are turned away with a message |44+| `--share` | `edit` | Sessions opening the same file share it: `edit`, everyone edits; `view`, one writes and the others follow; `off`, each session gets a copy of its own |4145 | `--log`, `--otlp` | off | Telemetry, as for the app ([Observability](../contributing/observability.md)) |4246 4347 The config directory is `os.UserConfigDir()`: `~/Library/Application@@ -134,20 +138,79 @@ served directory on the server (a symbolic link swapped in between the
134138 check and the open), and anything a holder of an authorized key does135139 with the files they're given.136140 141+## Sharing a workbook142+143+Sessions that open the same file (by any name that leads to it) are in144+one room, and so are those joining the same named room (`@standup`,145+which starts as a new sheet until someone saves it under a name). A146+room is one workbook the server holds: every session sees every change147+as it's made, whoever made it, while keeping its own cursor, scroll,148+selection, sheet shown, theme and modes. A session on a new sheet or an149+imported file has a workbook of its own until it's saved and opened150+again.151+152+153+154+155+**Who is where.** Each other person in the room has a color: their156+pointer's cell is drawn in it, double-underlined, their initial is on157+that row's header, and their name is on the status line. A cell someone158+else changed in the last 30 seconds has a `▘` in its corner, and the159+context line on it says who changed it and how; on someone's pointer it160+says who is there, and whether they're typing. **File > Who's here**161+lists everyone with the cell they're on, and picking one goes there.162+163+**Editing together.** Everyone edits (with `--share edit`). The server164+takes each session's keys and clicks in turn, so every change is made165+whole, in one order, through the one path every change takes in 012:166+two entries in one cell leave the later one, and the one who typed167+over the other's change is told, as is the one typing while someone168+else changed the cell.169+170+171+172+**Undo is yours.** Ctrl+Z takes back your own latest change, even when173+others changed other cells since: theirs stay. When someone changed the174+same cells after you (the same cells, widths, formats, names or rules,175+or rows and columns inserted or deleted on that sheet, or any change to176+the list of sheets), undo refuses and says who: take back yours by177+hand, or ask them. Redo is yours on the same terms. Formulas that read178+a cell you undid recalculate as usual.179+180+**One writer.** With `--share view`, the first in a room writes and181+the others follow: they move, scroll, select, copy and look around as182+they like, but can't change the workbook, and the status line says who183+writes. The writer hands writing over with **File > Hand over writing**;184+when the writer leaves, whoever has been there longest writes.185+186+**Saving is the room's.** Any session saves the one workbook to its187+file, and the others' `modified` clears with it. Quitting while others188+are in the room asks nothing: they still have the workbook. The last189+one to leave is asked about unsaved changes as in a local 012, and the190+last session to end without quitting keeps them (see191+[Unsaved work](#unsaved-work)).192+193+**What runs is the room's.** Files linked to the workbook are followed194+once for everyone, and notebook cells run once: any session may run or195+stop them, everyone sees them running and their outputs, and they go on196+when the one who started them leaves, until the last one does.197+137198 ## Each session138199 139-Every session is a separate 012 with nothing shared but the process:140-its own workbook, undo history, clipboard, theme, JEV cache and141-terminal state. What it learns about the terminal comes from the142-client: the window size and its changes, the colors (from the `TERM`143-the client sends), the light or dark background, kitty or sixel graphics, and144-the clipboard, which Ctrl+C sets on the client's machine through OSC 52.200+Besides the shared workbook, every session is a separate 012 with201+nothing shared but the process: its own cursor and selection, undo of202+its own changes, clipboard, theme, JEV cache and terminal state. What203+it learns about the terminal comes from the client: the window size and204+its changes, the colors (from the `TERM` the client sends), the light or205+dark background, kitty or sixel graphics, and the clipboard, which206+Ctrl+C sets on the client's machine through OSC 52.145207 146208 [Macros](../sheets/macros.md) can be recorded, run, renamed and deleted in a147209 session, but not edited as scripts: that would start an editor on the148210 server. A file's macros ask for trust once per session, since the149211 session isn't the server's own computer, and scripts have no file,150-network or clock access in any case.212+network or clock access in any case. A macro running in a shared213+workbook is one change: the others' keys wait until it ends.151214 152215 ## Notebooks153216 @@ -184,6 +247,11 @@ saving over a file that changed on disk still asks first. A sheet never
184247 saved is kept as `(untitled)-<time>.012` and offered when a session185248 starts on a new sheet.186249 250+In a shared workbook, only the last session in the room keeps them:251+one that ends while others remain leaves the workbook to them, and says252+so when it idled out. Restoring kept changes opens the room again on253+them, once nobody else is in it.254+187255 It stays small: nothing is kept for a workbook without changes or with188256 no cells, and only the newest three files per name are kept. The189257 directory is created `0700` and its files `0600`, and it's hidden, so no@@ -192,21 +260,25 @@ it. A session whose client just goes away (a dropped connection)
192260 keeps nothing, and neither does quitting, which asks about unsaved193261 changes as the local app does.194262 195-## Two sessions, one file263+## Files changed elsewhere196264 197-Opening the same file in two sessions gives two copies. Saving over a198-file that changed on disk since it was opened or last saved in this199-session (someone else saved it) asks first on the context line:200-Enter overwrites it, S saves under another name, Esc cancels. The same201-check protects the local app from other programs writing the file.265+Saving over a file that changed on disk since it was opened or last266+saved (another program wrote it, or a session with `--share off`) asks267+first on the context line: Enter overwrites it, S saves under another268+name, Esc cancels. The same check protects the local app from other269+programs writing the file.202270 203271 ## Limits204272 205273 - An idle session is closed without saving over its file; unsaved206274 changes go to a recovery file instead.207-- A dropped connection loses unsaved changes.275+- A dropped connection loses unsaved changes, unless others share the276+ workbook.277+- Sessions in one room take turns: a long change (a sort of a million278+ rows, a macro) makes the others wait for it.208279 - Each session holds its screen's buffers, about a megabyte, plus the209- sheets it opens; two sessions opening one file hold two copies. Fifty280+ sheets it opens; sessions sharing a file hold one workbook between281+ them. A change reaches the others' screens within a frame. Fifty210282 sessions typing at once get their frames as fast as ten do: see211283 [Bounds of support](../contributing/limits.md#serving-over-ssh) for212284 the measurements.@@ -67,6 +67,9 @@ type session struct {
6767 6868 exited chan struct{} // closed when the pty reader stops6969 stdout *lockedBuffer // a piped session's standard output70+ // peers are others' sessions of the same served file, for screens71+ // of a shared workbook (screen.peers).72+ peers []*session7073 }7174 7275 // options configures a session.@@ -10,11 +10,12 @@ import (
1010 // Golden screens for notebooks: cells with a note and table outputs, a1111 // cell edited with a long pipeline wrapped (at 80 and 60 columns), a1212 // failure, nu's highlighting and a problem nu found, a cell running and13-// one waiting, stale outputs, an output's grid entered with a range14-// selected, its filter open, and full-screen, and an output sent to a15-// sheet. Each is recorded on a light terminal and in the16-// high-contrast theme too. nu is testdata/nu/nu, which answers what the17-// screens run, and what the code editor asks, with fixed outputs.13+// one waiting, a cell running as a stream, stale outputs, an output's14+// grid entered with a range selected, its filter open, and full-screen,15+// and an output sent to a sheet. Each is recorded on a light terminal16+// and in the high-contrast theme too. nu is testdata/nu/nu, which17+// answers what the screens run, and what the code editor asks, with18+// fixed outputs.1819 1920 // fakeNu puts the stand-in nu first on the PATH.2021 var fakeNu = func() []string {@@ -129,6 +130,11 @@ var notebookScreens = []screen{
129130 s.keys("<esc>", "<down>", "<down>", "<down>", "<down>")130131 s.waitFor("rows 4 to 13 of 30")131132 }},133+ {name: "notebook-stream", setup: func(s *session) {134+ filesNotebook(s)135+ s.keys("<enter>", "log = tail -f app.log | lines | parse '{time} {level} {msg}'", "<esc>", "f")136+ s.waitFor("● live, 3 rows")137+ }},132138 {name: "notebook-sent", setup: func(s *session) {133139 filesNotebook(s)134140 s.keys("<up>", "<up>", "<up>", "<up>", "G", "<enter>")@@ -23,7 +23,14 @@ type screen struct {
2323 // ssh, when set, records the screen through 012 serve and a real2424 // ssh client, with these words on the ssh command line; files fills2525 // the served directory then.26- ssh []string26+ ssh []string27+ // serve is 012 serve's flags, e.g. --share view.28+ serve []string29+ // user is the ssh user the screen is recorded as, and peers the30+ // sessions of others that join the same served file first and act,31+ // so the screen shows them.32+ user string33+ peers []peer2734 args []string // 012's command line, e.g. --pipe2835 setup func(s *session)2936 }@@ -82,12 +89,47 @@ func startScreen(t *testing.T, sc screen) *session {
8289 if served == "" {8390 served = t.TempDir()8491 }85- opts.program, args = serveSSH(t, top, served)92+ opts.program, args = serveSSH(t, top, served, sc.serve...)8693 opts.dir, args = top, append(args, sc.ssh...)94+ var peers []*session95+ for _, p := range sc.peers {96+ peers = append(peers, p.join(t, opts, args))97+ }98+ if sc.user != "" {99+ args = asUser(sc.user, args)100+ }101+ s := startWith(t, opts, args...)102+ s.peers = peers103+ return s87104 }88105 return startWith(t, opts, args...)89106 }90107 108+// peer is someone else's session of a served file, for a screen that109+// shows others: who, and the keys they press once in.110+type peer struct {111+ user string112+ keys []string113+ // sees is what their screen shows once they've acted.114+ sees string115+}116+117+// join starts the peer's session through ssh with args, and acts.118+func (p peer) join(t *testing.T, opts options, args []string) *session {119+ o := options{dir: opts.dir, program: opts.program, cols: 80, rows: 24}120+ s := startWith(t, o, asUser(p.user, args)...)121+ s.keys(p.keys...)122+ if p.sees != "" {123+ s.waitFor(p.sees)124+ }125+ return s126+}127+128+// asUser is ssh's arguments args, logging in as user.129+func asUser(user string, args []string) []string {130+ return append([]string{"-l", user}, args...)131+}132+91133 // stableHTML waits until two captures in a row match, so a snapshot never92134 // catches a frame mid-render.93135 func (s *session) stableHTML() string {@@ -0,0 +1,68 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+// Golden screens for a workbook shared over 012 serve: two others in it10+// (their pointers in their colors, their initials on the row headers,11+// the cells they just changed marked, their names on the status line),12+// the list of who's here, an undo refused because another changed the13+// cell since, and a follower in a one-writer room. The first is also14+// recorded on a light terminal and in the high-contrast theme.15+16+// householdBudget is a small budget in budget.012.17+func householdBudget(t *testing.T, dir string) {18+ book := `{"version": 2, "cells": {"A1": "Household budget", "A3": "Rent", "B3": "1200", "A4": "Food", "B4": "450",19+ "A5": "Power", "B5": "90", "A6": "Total", "B6": "=SUM(B3:B5)"}}`20+ if err := os.WriteFile(filepath.Join(dir, "budget.012"), []byte(book), 0o644); err != nil {21+ t.Fatal(err)22+ }23+}24+25+// bobAndCy are two others in the budget: bob changes the food line and26+// moves to D4, cy adds a line and stays under it.27+var bobAndCy = []peer{28+ {user: "bob", keys: []string{"<down>", "<down>", "<down>", "<right>", "480", "<enter>", "<up>", "<right>", "<right>"}, sees: "1770"},29+ {user: "cy", keys: []string{"<down>", "<down>", "<down>", "<down>", "<down>", "<down>", "Gym", "<enter>"}, sees: "Gym"},30+}31+32+var shareScreens = []screen{33+ {name: "share-who", user: "ann", ssh: []string{"budget.012"}, files: householdBudget, peers: bobAndCy, setup: func(s *session) {34+ s.waitFor("Sharing budget.012 with bob and cy")35+ s.keys("<ctrl+k>", "who's here", "<enter>")36+ s.waitFor("Sheet1!D4")37+ }},38+ {name: "share-undo-refused", user: "ann", ssh: []string{"budget.012"}, files: householdBudget, peers: bobAndCy[:1], setup: func(s *session) {39+ s.waitFor("Sharing budget.012 with bob")40+ s.keys("<down>", "<down>", "<right>", "1300", "<enter>")41+ s.waitFor("1870")42+ bob := s.peers[0]43+ bob.waitFor("1870")44+ bob.keys("<left>", "<left>", "<up>", "1250", "<enter>")45+ s.waitFor("1820")46+ s.keys("<ctrl+z>")47+ s.waitFor("Can't undo")48+ }},49+ {name: "share-follow", serve: []string{"--share", "view"}, user: "ann", ssh: []string{"budget.012"}, files: householdBudget, peers: bobAndCy[:1],50+ setup: func(s *session) {51+ s.waitFor("bob writes")52+ s.keys("x")53+ s.waitFor("bob writes here and you follow")54+ }},55+}56+57+func init() {58+ presence := screen{name: "share-presence", user: "ann", ssh: []string{"budget.012"}, files: householdBudget, peers: bobAndCy, setup: func(s *session) {59+ s.waitFor("Sharing budget.012 with bob and cy")60+ }}61+ light, hc := presence, presence62+ light.name += "-light"63+ light.opts.light = true64+ hc.name += "-high-contrast"65+ hc.opts.config = highContrastConfig66+ screens = append(screens, presence, light, hc)67+ screens = append(screens, shareScreens...)68+}@@ -0,0 +1,52 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+// Two people reach one 012 serve with the system's ssh client, each in11+// a terminal of their own, and open the same file: what one types the12+// other sees, each sees where the other is, undo takes back only one's13+// own change, the first to quit leaves without being asked, and the one14+// left saves the file.15+func TestServeSharesAFile(t *testing.T) {16+ top := t.TempDir()17+ served := filepath.Join(top, "served")18+ os.Mkdir(served, 0o755)19+ sshPath, args := serveSSH(t, top, served)20+ args = append(args, "plan.012")21+22+ ann := startWith(t, options{dir: top, program: sshPath}, asUser("ann", args)...)23+ ann.waitFor("plan.012")24+ bob := startWith(t, options{dir: top, program: sshPath}, asUser("bob", args)...)25+ bob.waitFor("Sharing plan.012 with ann")26+ ann.waitFor(" bob ") // on ann's status line27+28+ ann.keys("rent", "<enter>")29+ bob.waitForLine(gridRow1, " 1 ▘rent") // marked: ann just changed it30+ bob.keys("<right>", "<down>", "450", "<enter>")31+ ann.waitFor("450")32+ // Bob's pointer is on B3 now: his initial is on row 3's header.33+ ann.eventually("bob's initial on row 3", func() bool { return strings.HasPrefix(ann.line(gridRow1+2), "b 3") })34+35+ // Ann's undo takes back her entry, not bob's.36+ ann.keys("<ctrl+z>")37+ bob.eventually("ann's undo", func() bool { return !strings.Contains(bob.screen(), "rent") })38+ if !strings.Contains(bob.screen(), "450") {39+ t.Fatalf("ann's undo took back bob's entry:\n%s", bob.screen())40+ }41+42+ bob.keys("<ctrl+q>") // ann still has it: no question43+ bob.waitExit()44+ ann.eventually("bob gone from the status line", func() bool { return !strings.Contains(ann.line(int(ann.rows)-1), " bob ") })45+ ann.keys("<ctrl+s>")46+ ann.eventually("plan.012 saved", func() bool {47+ data, err := os.ReadFile(filepath.Join(served, "plan.012"))48+ return err == nil && strings.Contains(string(data), "450")49+ })50+ ann.keys("<ctrl+q>")51+ ann.waitExit()52+}@@ -11,10 +11,10 @@ import (
1111 "time"1212 )1313 14-// serveSSH starts 012 serve on a random loopback port for served,15-// authorizing a new key, and returns the system's ssh client and the16-// arguments that reach the server with it, checking the host key.17-func serveSSH(t *testing.T, top, served string) (string, []string) {14+// serveSSH starts 012 serve on a random loopback port for served, with15+// flags, authorizing a new key, and returns the system's ssh client and16+// the arguments that reach the server with it, checking the host key.17+func serveSSH(t *testing.T, top, served string, flags ...string) (string, []string) {1818 t.Helper()1919 sshPath, err1 := exec.LookPath("ssh")2020 keygen, err2 := exec.LookPath("ssh-keygen")@@ -34,7 +34,7 @@ func serveSSH(t *testing.T, top, served string) (string, []string) {
3434 t.Fatal(err)3535 }3636 hostKey := filepath.Join(top, "config", "host_key")37- srv := exec.Command(binPath, "serve", "--listen", "127.0.0.1:0", "--authorized-keys", ak, "--host-key", hostKey, served)37+ srv := exec.Command(binPath, append(append([]string{"serve", "--listen", "127.0.0.1:0", "--authorized-keys", ak, "--host-key", hostKey}, flags...), served)...)3838 srv.Env = append(os.Environ(), "TYPESAFE_API_KEY=", "TYPESAFE_BASE_URL=")3939 stderr, err := srv.StderrPipe()4040 if err != nil {@@ -37,6 +37,7 @@ var docScreens = []docScreen{
3737 {name: "notebook-cells", from: "notebook-cells", cols: 80, rows: 26},3838 {name: "notebook-nu-error", from: "notebook-nu-error", cols: 80, rows: 16},3939 {name: "notebook-grid", from: "notebook-grid", cols: 80, rows: 24},40+ {name: "share-presence", from: "share-presence", cols: 80, rows: 13},4041 }4142 4243 // stillsVersion changes when the drawing does, so every still is@@ -0,0 +1,61 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+ "time"9+)10+11+// A cell run as a stream with the real nu follows a log as it's12+// written: its rows reach the output and the sheet it was sent to as13+// lines are appended, and Stop ends it, keeping them.14+func TestNotebookStreamWithNu(t *testing.T) {15+ needNu(t)16+ dir := t.TempDir()17+ logFile := filepath.Join(dir, "app.log")18+ if err := os.WriteFile(logFile, []byte("info started\n"), 0o644); err != nil {19+ t.Fatal(err)20+ }21+ s := startWith(t, options{dir: dir, startsOn: "EDIT"}, "nu")22+ s.keys("log = tail -n +1 -f app.log | lines | parse '{level} {msg}'", "<esc>", "f")23+ s.waitFor("● live, 1 row")24+ s.waitFor("started")25+26+ s.keys("G", "<enter>")27+ s.waitFor("Sent log to")28+ appendFile(t, logFile, "warn slow-query\nerror disk-full\n")29+ s.waitFor("● live, 3 rows")30+ s.waitFor("disk-full")31+32+ s.keys("<ctrl+pgdown>", "<ctrl+home>", "<right>", "<right>", "<right>", "=ROWS(nu.log)", "<enter>")33+ s.waitFor(" 4")34+ appendFile(t, logFile, "info recovered\n")35+ s.waitFor(" 5")36+37+ s.keys("<ctrl+pgup>", "i", "i")38+ s.waitFor("✓")39+ s.eventually("the stream stopped", func() bool { return !strings.Contains(s.screen(), "● live") })40+ appendFile(t, logFile, "info after-stop\n")41+ s.keys("<ctrl+pgdown>")42+ s.waitFor(" 5")43+ time.Sleep(time.Second)44+ if strings.Contains(s.screen(), " 6") {45+ t.Errorf("rows arrived after Stop:\n%s", s.screen())46+ }47+ s.keys("<ctrl+q>", "d")48+ s.waitExit()49+}50+51+func appendFile(t *testing.T, name, text string) {52+ t.Helper()53+ f, err := os.OpenFile(name, os.O_APPEND|os.O_WRONLY, 0)54+ if err != nil {55+ t.Fatal(err)56+ }57+ defer f.Close()58+ if _, err := f.WriteString(text); err != nil {59+ t.Fatal(err)60+ }61+}@@ -2,7 +2,8 @@
22 # A stand-in for nu in the golden screens: it answers the pipelines the33 # screens run with fixed outputs, so the screens don't depend on nu being44 # installed or on what ls finds. The script 012 runs is the last5-# argument; the pipeline is between its "do {" and "} | to nuon".5+# argument; the pipeline is between its "do {" and "} | to nuon", or6+# "} | each" for a cell run as a stream, which prints a value a line.67 for arg; do script=$arg; done78 # The code editor's questions (--ide-ast and the rest, about the file89 # named last) are answered as nu 0.116 answered them about the same@@ -27,7 +28,7 @@ if [ -n "$answer" ]; then
2728 echo "Error: not recorded" >&22829 exit 12930 fi30-cmd=$(printf '%s\n' "$script" | sed -n '/^do {$/,/^} | to nuon$/p' | sed '1d;$d')31+cmd=$(printf '%s\n' "$script" | sed -n '/^do {$/,/^} | /p' | sed '1d;$d')3132 case $cmd in3233 *'help commands'*)3334 echo '["ls", "where", "sort-by", "first", "get", "str join", "window", "sleep"]' ;;@@ -46,6 +47,11 @@ case $cmd in
4647 echo '{name: "012", files: 5, largest: README.md, total: 18.9kb}' ;;4748 'sleep'*)4849 sleep 600 ;;50+'tail -f'*)51+ echo '{time: "09:00:01", level: info, msg: "server started"}'52+ echo '{time: "09:00:04", level: warn, msg: "slow query, 812ms"}'53+ echo '{time: "09:00:09", level: info, msg: "GET /health 200"}'54+ exec sleep 600 ;;4955 *)5056 echo "Error: nu::parser::extra_positional" >&25157 echo "" >&2@@ -14,5 +14,5 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-0)">File</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-0)">File</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-0)">File</span> <span style="color:var(--vt-palette-0)">›</span> <span style="color:var(--vt-palette-0)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">226</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">227</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 Start a new, empty sheet <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">move</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -14,5 +14,5 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 Start a new, empty sheet <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -0,0 +1,13 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span> Household budget3+Sharing budget.012 with bob and cy4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 12008+<span style="color:var(--vt-palette-15);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power 9010+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 177011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-2)">▘</span>Gym12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">c</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-2);text-decoration:underline"> </span>13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">cy</span><span style="background:var(--vt-palette-2)"> </span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span></pre>\ No newline at end of file@@ -0,0 +1,13 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget3+Sharing budget.012 with bob and cy4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 177011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-2)">▘</span>Gym12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">c</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-2);text-decoration:underline"> </span>13+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">cy</span><span style="background:var(--vt-palette-2)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span></pre>\ No newline at end of file@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+A</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Space</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">130</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">1-25</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">131</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>3030 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Up/Down</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">scroll</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">close</span></pre>\ No newline at end of file@@ -11,6 +11,6 @@
1111 <span style="color:var(--vt-palette-8)">│</span> Last used cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+End</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1212 <span style="color:var(--vt-palette-8)">│</span> Go to <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+G</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F5</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">│</span>14-<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">130</span> <span style="color:var(--vt-palette-8)">─┘</span>14+<span style="color:var(--vt-palette-8)">└───────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-11</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">131</span> <span style="color:var(--vt-palette-8)">─┘</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -40,6 +40,6 @@
4040 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">36</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Time <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Shift+2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4141 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">37</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">Notebooks</span> Bold <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+B</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>4242 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">38</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Move between cells and outputs <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">j</span><span style="background:var(--vt-palette-8)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">k</span><span style="background:var(--vt-palette-8)"> </span> Italic <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+I</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>43-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">65</span> <span style="color:var(--vt-palette-8)">─┘</span>43+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">39</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-40</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">66</span> <span style="color:var(--vt-palette-8)">─┘</span>4444 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">40</span><span style="background:var(--vt-palette-8)"> </span>4545 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -25,6 +25,6 @@
2525 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Deselect <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2626 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select all <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+A</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>2727 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Select columns <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Space</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>28-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">130</span> <span style="color:var(--vt-palette-8)">─┘</span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└─────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">1-25</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">131</span> <span style="color:var(--vt-palette-8)">─┘</span>2929 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>3030 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">scroll</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">NOTEBOOK</span><span style="background:#40e0ff"> </span>2+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">▶</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Run</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">■</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">Stop</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">↻</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Restart</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">▶▶</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Run</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">all</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">+</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Add</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">✂</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Cut</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">⧉</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Copy</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">⎘</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">Paste</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">Code</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">▾</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nu</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">●</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">1</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">live</span><span style="background:#000000"> </span>3+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Following</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">cell</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">4</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">as</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">a</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">stream:</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">Stop</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">(i</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">i)</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">ends</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">it</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">──────────────────────────────────────────────────</span>4+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">Files</span><span style="background:#000000"> </span>5+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">What's</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">repo,</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="color:#ffffff;background:#000000">.</span><span style="background:#000000"> </span>6+<span style="background:#000000"> </span>7+<span style="background:#000000"> </span><span style="color:#757575;background:#000000">╭─</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">──────────────────────────────────────────────────────────────────────</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000"><1s</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">─╮</span><span style="background:#000000"> </span>8+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[1]:</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">▶</span>9+<span style="background:#000000"> </span><span style="color:#757575;background:#000000">╰──────────────────────────────────────────────────────────────────────────────────────╯</span><span style="background:#000000"> </span>10+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Out[1]:</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">name</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">type</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">size</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>11+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>12+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>13+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>14+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>15+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">5</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>16+<span style="background:#000000"> </span>17+<span style="background:#000000"> </span><span style="color:#757575;background:#000000">╭──────────────────────────────────────────────────────────────────────────────</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000"><1s</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">─╮</span><span style="background:#000000"> </span>18+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[2]:</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">></span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span><span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">▶</span>19+<span style="background:#000000"> </span><span style="color:#757575;background:#000000">╰──────────────────────────────────────────────────────────────────────────────────────╯</span><span style="background:#000000"> </span>20+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Out[2]:</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">name</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">type</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">size</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>21+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>22+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">2</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>23+<span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">3</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>24+<span style="background:#000000"> </span>25+<span style="color:#80a8ff;background:#000000">▌</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">╭─</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">log</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">───────────────────────────────────────────────────────────────</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">●</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">live,</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">3</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">rows</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">─╮</span><span style="background:#000000"> </span>26+<span style="color:#80a8ff;background:#000000">▌</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">[*]:</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">log</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">tail</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">-f</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">app.log</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">lines</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">parse</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">'{time}</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">{level}</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">{msg}'</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">■</span>27+<span style="color:#80a8ff;background:#000000">▌</span><span style="background:#000000"> </span><span style="color:#80a8ff;background:#000000">╰──────────────────────────────────────────────────────────────────────────────────────╯</span><span style="background:#000000"> </span>28+<span style="color:#80a8ff;background:#000000">▌</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">Out[3]:</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">time</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">level</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">msg</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span>29+<span style="color:#80a8ff;background:#000000">▌</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">1</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">09:00:01</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">info</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">server</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">started</span><span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">❯</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Notebook</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NOTEBOOK</span><span style="background:var(--vt-palette-6)"> </span>2+ ▶ Run <span style="color:var(--vt-palette-8)">■</span> <span style="color:var(--vt-palette-8)">Stop</span> ↻ Restart ▶▶ Run all <span style="color:var(--vt-palette-8)">│</span> + Add ✂ Cut ⧉ Copy <span style="color:var(--vt-palette-8)">⎘</span> <span style="color:var(--vt-palette-8)">Paste</span> <span style="color:var(--vt-palette-8)">│</span> Code ▾ <span style="color:var(--vt-palette-0)">nu</span> <span style="color:var(--vt-palette-0)">●</span> <span style="color:var(--vt-palette-0)">1</span> <span style="color:var(--vt-palette-0)">live</span>3+ <span style="color:var(--vt-palette-0)">Following</span> <span style="color:var(--vt-palette-0)">cell</span> <span style="color:var(--vt-palette-0)">4</span> <span style="color:var(--vt-palette-0)">as</span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">stream:</span> <span style="color:var(--vt-palette-0)">Stop</span> <span style="color:var(--vt-palette-0)">(i</span> <span style="color:var(--vt-palette-0)">i)</span> <span style="color:var(--vt-palette-0)">ends</span> <span style="color:var(--vt-palette-0)">it</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────</span>4+ <span style="font-weight:bold;text-decoration:underline">Files</span>5+ What's big in the repo, from <span style="color:var(--vt-palette-4)">ls</span>.6+7+ <span style="color:var(--vt-palette-8)">╭─</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)"><1s</span> <span style="color:var(--vt-palette-8)">─╮</span>8+ <span style="color:var(--vt-palette-0)">[1]:</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-4)">ls</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-0)">▶</span>9+ <span style="color:var(--vt-palette-8)">╰──────────────────────────────────────────────────────────────────────────────────────╯</span>10+ <span style="color:var(--vt-palette-0)">Out[1]:</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">name</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">type</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">size</span><span style="background:var(--vt-palette-7)"> </span>11+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> 012 file 4.2 kB12+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> CLAUDE.md file 1.0 kB13+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> README.md file 9.8 kB14+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> demos dir 3.1 kB15+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> go.mod file 812 B16+17+ <span style="color:var(--vt-palette-8)">╭──────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)"><1s</span> <span style="color:var(--vt-palette-8)">─╮</span>18+ <span style="color:var(--vt-palette-0)">[2]:</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> size <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-1)">1kb</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-0)">▶</span>19+ <span style="color:var(--vt-palette-8)">╰──────────────────────────────────────────────────────────────────────────────────────╯</span>20+ <span style="color:var(--vt-palette-0)">Out[2]:</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">name</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">type</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">size</span><span style="background:var(--vt-palette-7)"> </span>21+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> 012 file 4.2 kB22+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span> README.md file 9.8 kB23+ <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> demos dir 3.1 kB24+25+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-4)">╭─</span> <span style="color:var(--vt-palette-0)">log</span> <span style="color:var(--vt-palette-4)">───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">●</span> <span style="color:var(--vt-palette-0)">live,</span> <span style="color:var(--vt-palette-0)">3</span> <span style="color:var(--vt-palette-0)">rows</span> <span style="color:var(--vt-palette-4)">─╮</span>26+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-0)">[*]:</span> <span style="color:var(--vt-palette-4)">│</span> <span style="color:var(--vt-palette-5)">log</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-4)">tail</span> -f app.log <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">lines</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">parse</span> <span style="color:var(--vt-palette-2)">'{time}</span> <span style="color:var(--vt-palette-2)">{level}</span> <span style="color:var(--vt-palette-2)">{msg}'</span> <span style="color:var(--vt-palette-4)">│</span> <span style="color:var(--vt-palette-4)">■</span>27+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-4)">╰──────────────────────────────────────────────────────────────────────────────────────╯</span>28+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-0)">Out[3]:</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">time</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">level</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">msg</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="color:var(--vt-palette-4)">▌</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">1</span><span style="background:var(--vt-palette-7)"> </span> 09:00:01 info server started30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">NOTEBOOK</span><span style="background:var(--vt-palette-6)"> </span>2+ ▶ Run <span style="color:var(--vt-palette-8)">■</span> <span style="color:var(--vt-palette-8)">Stop</span> ↻ Restart ▶▶ Run all <span style="color:var(--vt-palette-8)">│</span> + Add ✂ Cut ⧉ Copy <span style="color:var(--vt-palette-8)">⎘</span> <span style="color:var(--vt-palette-8)">Paste</span> <span style="color:var(--vt-palette-8)">│</span> Code ▾ <span style="color:var(--vt-palette-8)">nu</span> <span style="color:var(--vt-palette-8)">●</span> <span style="color:var(--vt-palette-8)">1</span> <span style="color:var(--vt-palette-8)">live</span>3+ <span style="color:var(--vt-palette-8)">Following</span> <span style="color:var(--vt-palette-8)">cell</span> <span style="color:var(--vt-palette-8)">4</span> <span style="color:var(--vt-palette-8)">as</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">stream:</span> <span style="color:var(--vt-palette-8)">Stop</span> <span style="color:var(--vt-palette-8)">(i</span> <span style="color:var(--vt-palette-8)">i)</span> <span style="color:var(--vt-palette-8)">ends</span> <span style="color:var(--vt-palette-8)">it</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────</span>4+ <span style="font-weight:bold;text-decoration:underline">Files</span>5+ What's big in the repo, from <span style="color:var(--vt-palette-6)">ls</span>.6+7+ <span style="color:var(--vt-palette-8)">╭─</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">──────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)"><1s</span> <span style="color:var(--vt-palette-8)">─╮</span>8+ <span style="color:var(--vt-palette-8)">[1]:</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-6)">ls</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">▶</span>9+ <span style="color:var(--vt-palette-8)">╰──────────────────────────────────────────────────────────────────────────────────────╯</span>10+ <span style="color:var(--vt-palette-8)">Out[1]:</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">name</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">type</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">size</span><span style="background:var(--vt-palette-8)"> </span>11+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> 012 file 4.2 kB12+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> CLAUDE.md file 1.0 kB13+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> README.md file 9.8 kB14+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> demos dir 3.1 kB15+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> go.mod file 812 B16+17+ <span style="color:var(--vt-palette-8)">╭──────────────────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)"><1s</span> <span style="color:var(--vt-palette-8)">─╮</span>18+ <span style="color:var(--vt-palette-8)">[2]:</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> size <span style="font-weight:bold">></span> <span style="color:var(--vt-palette-3)">1kb</span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">▶</span>19+ <span style="color:var(--vt-palette-8)">╰──────────────────────────────────────────────────────────────────────────────────────╯</span>20+ <span style="color:var(--vt-palette-8)">Out[2]:</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">name</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">type</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">size</span><span style="background:var(--vt-palette-8)"> </span>21+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> 012 file 4.2 kB22+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> README.md file 9.8 kB23+ <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> demos dir 3.1 kB24+25+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-4)">╭─</span> <span style="color:var(--vt-palette-8)">log</span> <span style="color:var(--vt-palette-4)">───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">●</span> <span style="color:var(--vt-palette-8)">live,</span> <span style="color:var(--vt-palette-8)">3</span> <span style="color:var(--vt-palette-8)">rows</span> <span style="color:var(--vt-palette-4)">─╮</span>26+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-8)">[*]:</span> <span style="color:var(--vt-palette-4)">│</span> <span style="color:var(--vt-palette-5)">log</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-6)">tail</span> -f app.log <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">lines</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">parse</span> <span style="color:var(--vt-palette-2)">'{time}</span> <span style="color:var(--vt-palette-2)">{level}</span> <span style="color:var(--vt-palette-2)">{msg}'</span> <span style="color:var(--vt-palette-4)">│</span> <span style="color:var(--vt-palette-4)">■</span>27+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-4)">╰──────────────────────────────────────────────────────────────────────────────────────╯</span>28+<span style="color:var(--vt-palette-4)">▌</span> <span style="color:var(--vt-palette-8)">Out[3]:</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">time</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">level</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">msg</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="color:var(--vt-palette-4)">▌</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> 09:00:01 info server started30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -12,5 +12,5 @@
1212 <span style="color:var(--vt-palette-8)">│</span> Sort <span style="color:var(--vt-palette-3);font-weight:bold">s</span>heet Z to <span style="color:var(--vt-palette-3);font-weight:bold">A</span> <span style="color:var(--vt-palette-8)">│</span>1313 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Ask</span> <span style="color:var(--vt-palette-8)">JEV</span> <span style="color:var(--vt-palette-8)">again</span> <span style="color:var(--vt-palette-8)">│</span>1414 <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>ort r<span style="color:var(--vt-palette-3);font-weight:bold">a</span>nge <span style="color:var(--vt-palette-8)">│</span>15-<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">65</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>15+<span style="color:var(--vt-palette-8)">└──────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">66</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1616 Save the sheet u… <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-0)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-0)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-4);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-0)">Edit</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-0)">48</span> <span style="color:var(--vt-palette-0)">of</span> <span style="color:var(--vt-palette-0)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>1919 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>2020 <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Reset <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umn width <span style="color:var(--vt-palette-8)">Format</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Insert <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns right <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Delete rows or <span style="color:var(--vt-palette-3);font-weight:bold">col</span>umns <span style="color:var(--vt-palette-8)">Edit</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Alt+-</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">48</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-3);font-weight:bold">S</span>av<span style="color:var(--vt-palette-3);font-weight:bold">e</span> as <span style="color:var(--vt-palette-8)">Fi</span><span style="color:var(--vt-palette-3);font-weight:bold">l</span><span style="color:var(--vt-palette-8)">e</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-8)">Code</span> <span style="color:var(--vt-palette-8)">Data</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Shell</span> <span style="color:var(--vt-palette-8)">y</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> In<span style="color:var(--vt-palette-3);font-weight:bold">se</span>rt rows be<span style="color:var(--vt-palette-3);font-weight:bold">l</span>ow <span style="color:var(--vt-palette-8)">Insert</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">60</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└────────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">61</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -14,7 +14,7 @@
1414 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as JSON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1515 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as NUON <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>1616 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> Download as HTML <span style="color:var(--vt-palette-8)">File</span> <span style="color:var(--vt-palette-8)">›</span> <span style="color:var(--vt-palette-8)">Download</span> <span style="color:var(--vt-palette-8)">│</span>17-<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">226</span> <span style="color:var(--vt-palette-8)">─┘</span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">└───────────────────────────────────────────────────────────────</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">227</span> <span style="color:var(--vt-palette-8)">─┘</span>1818 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>1919 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>2020 <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget3+<span style="color:var(--vt-palette-3)">bob</span> <span style="color:var(--vt-palette-3)">writes</span> <span style="color:var(--vt-palette-3)">here</span> <span style="color:var(--vt-palette-3)">and</span> <span style="color:var(--vt-palette-3)">you</span> <span style="color:var(--vt-palette-3)">follow:</span> <span style="color:var(--vt-palette-3)">they</span> <span style="color:var(--vt-palette-3)">can</span> <span style="color:var(--vt-palette-3)">hand</span> <span style="color:var(--vt-palette-3)">writing</span> <span style="color:var(--vt-palette-3)">to</span> <span style="color:var(--vt-palette-3)">you</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 177011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="color:var(--vt-palette-8)">bob</span> <span style="color:var(--vt-palette-8)">writes</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget3+Sharing budget.012 with bob and cy4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 177011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-2)">▘</span>Gym12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">c</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-2);text-decoration:underline"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">cy</span><span style="background:var(--vt-palette-2)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">A1</span><span style="background:var(--vt-palette-7)"> </span> Household budget3+Sharing budget.012 with bob and cy4+<span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">B</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">C</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">D</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">E</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">G</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">H</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">I</span><span style="background:var(--vt-palette-7)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">2</span><span style="background:var(--vt-palette-7)"> </span>7+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">3</span><span style="background:var(--vt-palette-7)"> </span> Rent 12008+<span style="color:var(--vt-palette-15);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">5</span><span style="background:var(--vt-palette-7)"> </span> Power 9010+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">6</span><span style="background:var(--vt-palette-7)"> </span> Total 177011+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">7</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-2)">▘</span>Gym12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">c</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">8</span><span style="background:var(--vt-palette-7)"> </span><span style="background:var(--vt-palette-2);text-decoration:underline"> </span>13+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">9</span><span style="background:var(--vt-palette-7)"> </span>14+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">10</span><span style="background:var(--vt-palette-7)"> </span>15+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">11</span><span style="background:var(--vt-palette-7)"> </span>16+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">12</span><span style="background:var(--vt-palette-7)"> </span>17+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">13</span><span style="background:var(--vt-palette-7)"> </span>18+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">14</span><span style="background:var(--vt-palette-7)"> </span>19+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">15</span><span style="background:var(--vt-palette-7)"> </span>20+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">16</span><span style="background:var(--vt-palette-7)"> </span>21+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">17</span><span style="background:var(--vt-palette-7)"> </span>22+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">18</span><span style="background:var(--vt-palette-7)"> </span>23+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">19</span><span style="background:var(--vt-palette-7)"> </span>24+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">20</span><span style="background:var(--vt-palette-7)"> </span>25+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">21</span><span style="background:var(--vt-palette-7)"> </span>26+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">22</span><span style="background:var(--vt-palette-7)"> </span>27+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">23</span><span style="background:var(--vt-palette-7)"> </span>28+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">24</span><span style="background:var(--vt-palette-7)"> </span>29+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">25</span><span style="background:var(--vt-palette-7)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">cy</span><span style="background:var(--vt-palette-2)"> </span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget3+Sharing budget.012 with bob and cy4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 12008+<span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 177011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-2)">▘</span>Gym12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">c</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-2);text-decoration:underline"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="background:var(--vt-palette-2)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">cy</span><span style="background:var(--vt-palette-2)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B4</span><span style="background:var(--vt-palette-8)"> </span> 4803+<span style="color:var(--vt-palette-3)">Can't</span> <span style="color:var(--vt-palette-3)">undo</span> <span style="color:var(--vt-palette-3)">edit</span> <span style="color:var(--vt-palette-3)">B3:</span> <span style="color:var(--vt-palette-3)">bob</span> <span style="color:var(--vt-palette-3)">has</span> <span style="color:var(--vt-palette-3)">changed</span> <span style="color:var(--vt-palette-3)">it</span> <span style="color:var(--vt-palette-3)">since,</span> <span style="color:var(--vt-palette-3)">and</span> <span style="color:var(--vt-palette-3)">undo</span> <span style="color:var(--vt-palette-3)">takes</span> <span style="color:var(--vt-palette-3)">back</span> <span style="color:var(--vt-palette-3)">only</span> <span style="color:var(--vt-palette-3)">your</span> <span style="color:var(--vt-palette-3)">own</span> <span style="color:var(--vt-palette-3)">changes</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent <span style="color:var(--vt-palette-5)">▘</span> 12508+<span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">4</span><span style="background:var(--vt-palette-6)"> </span> Food <span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">▘</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">480</span><span style="background:var(--vt-palette-6)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 182011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> budget.012 <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-5)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">bob</span><span style="background:var(--vt-palette-5)"> </span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span> Household budget <span style="color:var(--vt-palette-8)">┌─</span> <span style="font-weight:bold">Who's</span> <span style="font-weight:bold">here</span> <span style="color:var(--vt-palette-8)">───────────────────────────┐</span>3+ <span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold">›</span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">name</span> <span style="color:var(--vt-palette-8)">│</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-8)">├────────────────────────────────────────┤</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Household</span> budget <span style="color:var(--vt-palette-8)">│</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">bob</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">Sheet1!D4</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-8)">│</span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">│</span> cy <span style="color:var(--vt-palette-8)">Sheet1!A8</span> <span style="color:var(--vt-palette-8)">│</span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 1200 <span style="color:var(--vt-palette-8)">└───────────────────────────────</span> <span style="color:var(--vt-palette-8)">2</span> <span style="color:var(--vt-palette-8)">of</span> <span style="color:var(--vt-palette-8)">2</span> <span style="color:var(--vt-palette-8)">─┘</span>8+<span style="color:var(--vt-palette-0);background:var(--vt-palette-5)">b</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Food <span style="color:var(--vt-palette-5)">▘</span> 480 <span style="background:var(--vt-palette-5);text-decoration:underline"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Power 9010+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Total 177011+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-2)">▘</span>Gym12+<span style="color:var(--vt-palette-0);background:var(--vt-palette-2)">c</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-2);text-decoration:underline"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+Go to bob's cell <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Up/Down</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">move</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">go</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">close</span></pre>\ No newline at end of file@@ -170,6 +170,9 @@ var Options = []Option{
170170 Desc: "End a 012 serve session that has had no input for this long; 0 never does."},171171 {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",172172 Desc: "How many 012 serve sessions may run at once; more are turned away."},173+ {Name: "serve-share", Kind: Enum, Group: GroupServe, Default: "edit", Values: []string{"edit", "view", "off"},174+ Desc: "Whether 012 serve sessions opening the same file share its workbook: edit, everyone edits; " +175+ "view, one writes and the others follow until writing is handed over; off, each session has its own copy."},173176 {Name: "serve-shell", Kind: Bool, Group: GroupServe, Default: "false",174177 Desc: "Let 012 serve sessions run notebooks' code cells, as the user 012 serve runs as, " +175178 "following the shell option. Off, served notebooks show their cells and saved outputs but run nothing."},@@ -0,0 +1,214 @@
1+package live2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "io"8+ "sync"9+ "time"10+11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// Stream follows a program that prints a table as it makes it: NUON16+// values one after another, one to a line, as a notebook cell run as a17+// stream prints them (nushell.Stream). The program starts with the18+// stream and runs on a goroutine of its own until it ends or the stream19+// is closed. A poll waits up to Wait for something to arrive, so rows20+// reach the region as soon as the program prints them, and the owner21+// polls again at once rather than after Interval.22+//23+// It keeps the last Keep values printed, for the cell's output (NUON)24+// and for reading the table whole again (Reload).25+type Stream struct {26+ mu sync.Mutex27+ pending []byte // printed since the last poll28+ ended bool29+ err error // why the program ended, nil when it ended well30+ arrived chan struct{}31+ cancel context.CancelFunc32+33+ // The rest is the poller's: polls never overlap. values is guarded34+ // by mu too, since the owner reads it (NUON).35+ partial []byte // a line not yet ended36+ tail *fileio.Tail37+ values [][]byte38+ header sheet.LiveRow39+ rows []sheet.LiveRow40+ total int // rows printed in all; guarded by mu41+ reload bool42+ done bool // the end was reported; guarded by mu43+44+ // Wait is how long a poll waits for the program to print.45+ Wait time.Duration46+ // Keep is how many of the last values are kept.47+ Keep int48+ // Now is the clock; tests replace it.49+ Now func() time.Time50+}51+52+// StreamKeep is how many of the last values a stream keeps.53+const StreamKeep = 1000054+55+// NewStream starts run, which prints the table to w until ctx is done,56+// and follows what it prints.57+func NewStream(run func(ctx context.Context, w io.Writer) error) *Stream {58+ ctx, cancel := context.WithCancel(context.Background())59+ s := &Stream{arrived: make(chan struct{}, 1), cancel: cancel, Wait: Interval, Keep: StreamKeep, Now: time.Now}60+ s.tail, _ = fileio.NewTail(fileio.NUON, nil, nil)61+ go func() {62+ err := run(ctx, streamWriter{s})63+ s.mu.Lock()64+ s.ended, s.err = true, err65+ s.mu.Unlock()66+ s.kick()67+ }()68+ return s69+}70+71+// streamWriter takes what the program prints.72+type streamWriter struct{ s *Stream }73+74+func (w streamWriter) Write(p []byte) (int, error) {75+ w.s.mu.Lock()76+ w.s.pending = append(w.s.pending, p...)77+ w.s.mu.Unlock()78+ w.s.kick()79+ return len(p), nil80+}81+82+// kick tells a poll waiting that something arrived.83+func (s *Stream) kick() {84+ select {85+ case s.arrived <- struct{}{}:86+ default:87+ }88+}89+90+// Poll waits up to Wait for the program to print, and returns the rows91+// it completed: all of those kept again after Reload, and the reason92+// it failed once it ends with an error.93+func (s *Stream) Poll(ctx context.Context) (Update, bool) {94+ if done, _ := s.Ended(); done {95+ return Update{}, false96+ }97+ t := time.NewTimer(s.Wait)98+ select {99+ case <-s.arrived:100+ case <-t.C:101+ case <-ctx.Done():102+ }103+ t.Stop()104+ s.mu.Lock()105+ got, ended, err := s.pending, s.ended, s.err106+ s.pending = nil107+ s.mu.Unlock()108+109+ u := Update{At: s.Now()}110+ s.read(got, ended, &u)111+ if s.reload {112+ s.reload = false113+ u.Reset, u.Header, u.Rows = true, s.header, s.rows114+ }115+ if ended && !s.done {116+ s.mu.Lock()117+ s.done = true118+ s.mu.Unlock()119+ if err != nil && !isStop(err) {120+ u.Err = err.Error()121+ }122+ return u, true123+ }124+ return u, u.Reset || len(u.Rows) > 0 || u.Header != nil125+}126+127+// read parses the complete lines of what arrived into u, keeping them.128+func (s *Stream) read(got []byte, ended bool, u *Update) {129+ s.partial = append(s.partial, got...)130+ end := bytes.LastIndexByte(s.partial, '\n') + 1131+ if ended {132+ end = len(s.partial)133+ }134+ if end == 0 {135+ return136+ }137+ text := s.partial[:end]138+ s.partial = append([]byte(nil), s.partial[end:]...)139+ var lines [][]byte140+ for line := range bytes.SplitSeq(text, []byte("\n")) {141+ if line = bytes.TrimSpace(line); len(line) > 0 {142+ lines = append(lines, append([]byte(nil), line...))143+ }144+ }145+ if len(lines) == 0 {146+ return147+ }148+ s.mu.Lock()149+ s.values = keepLast(append(s.values, lines...), s.Keep)150+ s.mu.Unlock()151+ res, err := s.tail.Feed(append(bytes.Join(lines, []byte("\n")), '\n'))152+ if err != nil {153+ u.Err = err.Error()154+ }155+ if res.Header != nil {156+ s.header = res.Header157+ u.Header = res.Header158+ }159+ s.mu.Lock()160+ s.total += len(res.Rows)161+ s.mu.Unlock()162+ s.rows = keepLast(append(s.rows, res.Rows...), s.Keep)163+ u.Rows = res.Rows164+}165+166+// keepLast is the last n of xs.167+func keepLast[T any](xs []T, n int) []T {168+ if n > 0 && len(xs) > n {169+ return append(xs[:0:0], xs[len(xs)-n:]...)170+ }171+ return xs172+}173+174+// isStop reports whether err is the stream being stopped rather than175+// failing.176+func isStop(err error) bool { return errors.Is(err, context.Canceled) }177+178+// Reload has the next poll give every row kept again.179+func (s *Stream) Reload() { s.reload = true }180+181+// Close stops the program; the stream is polled no more.182+func (s *Stream) Close() {183+ s.cancel()184+ s.tail.Close()185+}186+187+// Ended reports whether the program has ended and the last poll gave188+// what it printed, and why it failed if it did (nil when it ended well189+// or was stopped).190+func (s *Stream) Ended() (bool, error) {191+ s.mu.Lock()192+ defer s.mu.Unlock()193+ if !s.done {194+ return false, nil195+ }196+ if isStop(s.err) {197+ return true, nil198+ }199+ return true, s.err200+}201+202+// Rows is how many rows the program has printed so far.203+func (s *Stream) Rows() int {204+ s.mu.Lock()205+ defer s.mu.Unlock()206+ return s.total207+}208+209+// NUON is the values kept, as a NUON list: the cell's output so far.210+func (s *Stream) NUON() []byte {211+ s.mu.Lock()212+ defer s.mu.Unlock()213+ return append(append([]byte("["), bytes.Join(s.values, []byte(", "))...), ']')214+}@@ -0,0 +1,93 @@
1+package live2+3+import (4+ "context"5+ "errors"6+ "io"7+ "testing"8+ "time"9+)10+11+// A stream's rows come as the program prints them, a line cut in two12+// waiting for its end; Reload gives them all again, and the end is13+// reported once, with the error it ended on.14+func TestStreamRowsAsPrinted(t *testing.T) {15+ next := make(chan string)16+ s := NewStream(func(ctx context.Context, w io.Writer) error {17+ for text := range next {18+ io.WriteString(w, text)19+ }20+ return errors.New("tail: app.log: no such file")21+ })22+ defer s.Close()23+ s.Wait = time.Second24+ ctx := context.Background()25+26+ next <- "{x: a, y: 1}\n{x: b"27+ u, ok := s.Poll(ctx)28+ if !ok || len(u.Rows) != 1 || u.Header == nil || u.Header[0].V.Str != "x" || u.Rows[0][1].V.Num != 1 {29+ t.Fatalf("first poll: %v %+v", ok, u)30+ }31+ next <- ", y: 2}\n"32+ if u, ok = s.Poll(ctx); !ok || len(u.Rows) != 1 || u.Rows[0][0].V.Str != "b" || u.Header != nil {33+ t.Fatalf("second poll: %v %+v", ok, u)34+ }35+ if got := string(s.NUON()); got != "[{x: a, y: 1}, {x: b, y: 2}]" {36+ t.Errorf("NUON %s", got)37+ }38+ s.Reload()39+ next <- "{x: c, y: 3}\n"40+ if u, ok = s.Poll(ctx); !ok || !u.Reset || len(u.Rows) != 3 || u.Header == nil {41+ t.Fatalf("after Reload: %v %+v", ok, u)42+ }43+ if done, _ := s.Ended(); done {44+ t.Fatal("ended too soon")45+ }46+ close(next)47+ u, ok = s.Poll(ctx)48+ if !ok || u.Err != "tail: app.log: no such file" {49+ t.Fatalf("the end: %v %+v", ok, u)50+ }51+ if done, err := s.Ended(); !done || err == nil || s.Rows() != 3 {52+ t.Errorf("ended %v %v, %d rows", done, err, s.Rows())53+ }54+ if _, ok := s.Poll(ctx); ok {55+ t.Error("polled after the end")56+ }57+}58+59+// Closing a stream stops its program, which ends without an error.60+func TestStreamClose(t *testing.T) {61+ s := NewStream(func(ctx context.Context, w io.Writer) error {62+ io.WriteString(w, "1\n")63+ <-ctx.Done()64+ return ctx.Err()65+ })66+ s.Wait = time.Second67+ if u, ok := s.Poll(context.Background()); !ok || len(u.Rows) != 1 {68+ t.Fatalf("%v %+v", ok, u)69+ }70+ s.Close()71+ u, ok := s.Poll(context.Background())72+ if !ok || u.Err != "" {73+ t.Fatalf("after Close: %v %+v", ok, u)74+ }75+ if done, err := s.Ended(); !done || err != nil {76+ t.Errorf("ended %v %v", done, err)77+ }78+}79+80+// Only the last Keep values are kept.81+func TestStreamKeepsTheLast(t *testing.T) {82+ s := NewStream(func(ctx context.Context, w io.Writer) error {83+ io.WriteString(w, "1\n2\n3\n")84+ return nil85+ })86+ s.Keep = 287+ for done := false; !done; done, _ = s.Ended() {88+ s.Poll(context.Background())89+ }90+ if got := string(s.NUON()); got != "[2, 3]" {91+ t.Errorf("NUON %s", got)92+ }93+}@@ -100,3 +100,25 @@ func TestNu(t *testing.T) {
100100 t.Error("a missing nu ran")101101 }102102 }103+104+// A stream's script prints each value the pipeline yields on a line of105+// its own, and Stream has no timeout of its own: it ends with the106+// pipeline or when stopped.107+func TestStream(t *testing.T) {108+ got := StreamScript(Job{Command: "tail -f app.log | lines", Tables: map[string][]byte{"r1": nil}}, []string{"r1"})109+ want := "let r1 = (open --raw $env.NU012_TABLE_0 | from nuon)\n" +110+ "do {\ntail -f app.log | lines\n} | each {|row| $row | to nuon --raw | print } | ignore\n"111+ if got != want {112+ t.Errorf("script:\n%s\nwant:\n%s", got, want)113+ }114+ var b strings.Builder115+ f := &fake{out: "{a: 1}\n{a: 2}\n"}116+ if err := Stream(context.Background(), f, Job{Command: "x"}, &b); err != nil || b.String() != f.out || !strings.Contains(f.script, "print") {117+ t.Errorf("%q %v", b.String(), err)118+ }119+ ctx, cancel := context.WithCancel(context.Background())120+ cancel()121+ if err := Stream(ctx, f, Job{Command: "x"}, &b); !errors.Is(err, context.Canceled) {122+ t.Errorf("stopped: %v", err)123+ }124+}@@ -0,0 +1,37 @@
1+package nushell2+3+import (4+ "context"5+ "fmt"6+ "io"7+ "strings"8+)9+10+// Streams: a cell run as a stream prints each value its pipeline yields11+// as soon as the pipeline yields it, one NUON value to a line, rather12+// than the whole value once the pipeline ends, so a pipeline that never13+// ends (tail -f app.log | lines, watch) is followed as it goes. The14+// lines are what nuon.Reader reads as a stream of rows.15+16+// StreamScript is what nu runs for job as a stream: the tables bound to17+// their names, as Script binds them, then the pipeline, each value it18+// yields printed as NUON on a line of its own.19+func StreamScript(job Job, names []string) string {20+ var b strings.Builder21+ for i, name := range names {22+ fmt.Fprintf(&b, "let %s = (open --raw $env.NU012_TABLE_%d | from nuon)\n", name, i)23+ }24+ b.WriteString("do {\n" + job.Command + "\n} | each {|row| $row | to nuon --raw | print } | ignore\n")25+ return b.String()26+}27+28+// Stream runs job as a stream, writing to w a line of NUON for each29+// value the pipeline yields, until the pipeline ends or ctx is done. It30+// has no timeout: the stream runs until it's stopped.31+func Stream(ctx context.Context, r Runner, job Job, w io.Writer) error {32+ err := r.Run(ctx, job, StreamScript(job, sortedNames(job.Tables)), w)33+ if ctx.Err() != nil {34+ return context.Cause(ctx)35+ }36+ return err37+}@@ -66,6 +66,10 @@ func (p *Pages) Same(h fileio.Source, gen int, o *sheet.SourceOrder) bool {
6666 return p.h == h && p.gen == gen && sameOrder(p.order, ord)6767 }6868 69+// Of reports whether the pages are of h's gen'th opening, however70+// ordered.71+func (p *Pages) Of(h fileio.Source, gen int) bool { return p.h == h && p.gen == gen }72+6973 func sameOrder(a, b sheet.SourceOrder) bool {7074 return slices.Equal(a.Sort, b.Sort) && slices.Equal(a.Filter, b.Filter)7175 }@@ -0,0 +1,112 @@
1+package room2+3+import (4+ "sync"5+ "time"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Registry is the rooms a server holds, by key: a file's path, or @name11+// for a named room.12+type Registry struct {13+ mode Mode14+ mu sync.Mutex15+ rooms map[string]*Room16+}17+18+// NewRegistry holds rooms whose participants edit as mode says.19+func NewRegistry(mode Mode) *Registry {20+ return &Registry{mode: mode, rooms: map[string]*Room{}}21+}22+23+// Has reports whether a room with key is open.24+func (g *Registry) Has(key string) bool {25+ g.mu.Lock()26+ defer g.mu.Unlock()27+ return g.rooms[key] != nil28+}29+30+// Rooms is how many rooms are open.31+func (g *Registry) Rooms() int {32+ g.mu.Lock()33+ defer g.mu.Unlock()34+ seen := map[*Room]bool{}35+ for _, r := range g.rooms {36+ seen[r] = true37+ }38+ return len(seen)39+}40+41+// Join seats p in the room with key, opening it on the workbook book42+// makes when there's none (book is called with no lock held but the43+// registry's, and may be nil when the room is known to be open). It44+// reports whether it opened the room. A room's history is shared: undo45+// takes back each participant's own steps.46+func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook) (*Seat, bool) {47+ g.mu.Lock()48+ defer g.mu.Unlock()49+ r := g.rooms[key]50+ created := false51+ if r == nil {52+ w := book()53+ w.ShareHistory()54+ r = &Room{key: key, reg: g, book: w, mode: g.mode}55+ r.seen, r.version = w.LastOp(), w.Version()56+ g.rooms[key] = r57+ created = true58+ }59+ r.mu.Lock()60+ defer r.mu.Unlock()61+ r.nextID++62+ s := &Seat{room: r, id: r.nextID, name: p.Name(), color: r.freeColor(), p: p, joined: time.Now()}63+ if len(r.seats) == 0 {64+ r.writer = s.id65+ }66+ r.seats = append(r.seats, s)67+ r.notify(s)68+ return s, created69+}70+71+// freeColor is the first color no one in the room has.72+func (r *Room) freeColor() int {73+ used := map[int]bool{}74+ for _, s := range r.seats {75+ used[s.color] = true76+ }77+ for c := range Colors {78+ if !used[c] {79+ return c80+ }81+ }82+ return r.nextID % Colors83+}84+85+// alias makes the room also the one of key, a name it was saved under,86+// unless another room has it.87+func (g *Registry) alias(r *Room, key string) {88+ g.mu.Lock()89+ defer g.mu.Unlock()90+ if g.rooms[key] == nil {91+ g.rooms[key] = r92+ }93+}94+95+// close forgets the room once its last seat has left, reporting whether96+// it did: someone may have joined since.97+func (g *Registry) close(r *Room) bool {98+ g.mu.Lock()99+ defer g.mu.Unlock()100+ r.mu.Lock()101+ defer r.mu.Unlock()102+ if len(r.seats) > 0 || r.closed {103+ return false104+ }105+ r.closed = true106+ for k, x := range g.rooms {107+ if x == r {108+ delete(g.rooms, k)109+ }110+ }111+ return true112+}@@ -0,0 +1,376 @@
1+// Package room shares workbooks between the people 012 serve hosts: a2+// room is one workbook, opened by every session that opens the same3+// file (or joins the same named room), and each participant has a seat4+// in it. The server holds the workbook, so there is no merging: every5+// participant acts on it in turn, under the room's lock, and the steps6+// they make are ordered by the workbook's one mutation path (Batch and7+// its undo steps), each attributed to its author, as a stream of8+// operations (sheet.Op) the room hands every participant. A participant9+// is anything with a name that can be told the room changed10+// (Participant): a served session's model. The interface stays that11+// small so an MCP client attached to the server can take part the same12+// way (ROADMAP.md, Agents). See docs/contributing/architecture.md.13+package room14+15+import (16+ "slices"17+ "sync"18+ "time"19+20+ "github.com/FelineStateMachine/012/internal/sheet"21+)22+23+// Participant is one who takes part in a room. The room asks only this24+// of it; everything else a participant does, it does through its Seat.25+type Participant interface {26+ // Name is who they are to the others: the SSH user of a session.27+ Name() string28+ // Notify tells them the room changed: someone else's operations,29+ // where the others are, who writes, a save, a message posted for30+ // them. It's called with the room locked, so it must neither block31+ // nor call back into the room: they look at what changed on their32+ // next turn (Seat.Do).33+ Notify()34+}35+36+// Mode is who may change a room's workbook.37+type Mode int38+39+const (40+ // Edit lets every participant edit.41+ Edit Mode = iota42+ // View lets one participant, the writer, edit; the others follow.43+ // The writer hands the role over explicitly (Seat.HandTo).44+ View45+)46+47+// Colors is how many colors participants are told apart by.48+const Colors = 649+50+// Room is one shared workbook and the seats of those in it.51+type Room struct {52+ key string53+ reg *Registry54+55+ mu sync.Mutex56+ book *sheet.Workbook57+ seats []*Seat // in the order they joined58+ nextID int59+ mode Mode60+ writer int // the seat that writes in View mode61+ saved Saved62+ values map[string]any63+ inbox []any64+ closed bool65+66+ // seen is the last operation the room has looked at, version the67+ // workbook's when a turn last ended, and marks the latest68+ // operations' cells, for who changed what.69+ seen uint6470+ version uint6471+ marks []Mark72+ // alias is a key the room was saved under this turn, for the73+ // registry once the room is unlocked.74+ alias string75+}76+77+// Saved is the room's file as last saved or opened: what every session78+// in it counts unsaved changes from.79+type Saved struct {80+ State int // the workbook's StateID then81+ Outputs int // its OutputsChanged then82+ Name string // the file's name, as typed, "" for none83+ Key string // the file's room key, its path: the room is its room too84+ Stamp any // the file on disk then, the UI's to compare85+}86+87+// Presence is where a participant is.88+type Presence struct {89+ Sheet *sheet.Sheet90+ Cursor sheet.Addr91+ Selection sheet.Rect92+ Editing bool // typing into the cell at Cursor93+}94+95+// Seat is a participant's place in a room.96+type Seat struct {97+ room *Room98+ id int99+ name string100+ color int101+ p Participant102+ joined time.Time103+ presence Presence104+ moved bool // presence changed this turn105+ left bool106+}107+108+// Peer is what a participant sees of another: who, in what color,109+// where, and whether they write.110+type Peer struct {111+ ID int112+ Name string113+ Color int114+ Presence Presence115+ Writing bool116+ Joined time.Time117+}118+119+// Mark is an operation's place in the workbook: who changed which120+// cells, for showing whose change a cell holds.121+type Mark struct {122+ Seq uint64123+ Author int124+ Name string125+ Color int126+ Kind sheet.OpKind127+ Label string128+ Sheet *sheet.Sheet129+ Rect sheet.Rect130+ At time.Time131+}132+133+// maxMarks is how many of the latest operations the room remembers the134+// cells of.135+const maxMarks = 256136+137+// ID identifies the seat in the room: the author of its steps.138+func (s *Seat) ID() int { return s.id }139+140+// Name is the participant's name.141+func (s *Seat) Name() string { return s.name }142+143+// Color is the participant's color, from 0 to Colors-1.144+func (s *Seat) Color() int { return s.color }145+146+// Key is the room's key: the file's path, or @name for a named room.147+func (s *Seat) Key() string { return s.room.key }148+149+// Do runs fn as the participant's turn: with the room locked, the150+// workbook's steps made in the participant's name. When the turn ends,151+// the others are told of what changed.152+func (s *Seat) Do(fn func(w *sheet.Workbook)) {153+ r := s.room154+ r.mu.Lock()155+ defer r.endTurn(s)156+ r.book.SetAuthor(s.id)157+ fn(r.book)158+}159+160+// endTurn takes note of the operations the turn made, and tells the161+// others when anything changed.162+func (r *Room) endTurn(by *Seat) {163+ changed := r.catchUp()164+ if v := r.book.Version(); v != r.version {165+ r.version, changed = v, true166+ }167+ if changed || by.moved {168+ by.moved = false169+ r.notify(by)170+ }171+ alias := r.alias172+ r.alias = ""173+ r.mu.Unlock()174+ if alias != "" {175+ r.reg.alias(r, alias)176+ }177+}178+179+// catchUp marks the cells of the operations since the room last looked.180+func (r *Room) catchUp() bool {181+ ops, last := r.book.OpsSince(r.seen)182+ if last == r.seen {183+ return false184+ }185+ r.seen = last186+ now := time.Now()187+ for _, o := range ops {188+ if o.Tabs || o.Macros || o.Sheet == nil {189+ continue190+ }191+ m := Mark{Seq: o.Seq, Author: o.Author, Kind: o.Kind, Label: o.Label, Sheet: o.Sheet, Rect: o.Focus, At: now}192+ if s := r.seat(o.Author); s != nil {193+ m.Name, m.Color = s.name, s.color194+ }195+ r.marks = append(r.marks, m)196+ }197+ if len(r.marks) > maxMarks {198+ r.marks = slices.Delete(r.marks, 0, len(r.marks)-maxMarks)199+ }200+ return true201+}202+203+// notify tells every seat but by that the room changed.204+func (r *Room) notify(by *Seat) {205+ for _, s := range r.seats {206+ if s != by {207+ s.p.Notify()208+ }209+ }210+}211+212+// seat is the seat with id, nil when it left.213+func (r *Room) seat(id int) *Seat {214+ for _, s := range r.seats {215+ if s.id == id {216+ return s217+ }218+ }219+ return nil220+}221+222+// Book is the room's workbook. Only use it in a turn (Do).223+func (s *Seat) Book() *sheet.Workbook { return s.room.book }224+225+// Move sets where the participant is, for the others to see once the226+// turn ends. Call it in a turn.227+func (s *Seat) Move(p Presence) {228+ if p != s.presence {229+ s.presence, s.moved = p, true230+ }231+}232+233+// Peers are the others in the room, in the order they joined. Call it234+// in a turn.235+func (s *Seat) Peers() []Peer {236+ var out []Peer237+ for _, o := range s.room.seats {238+ if o != s {239+ out = append(out, o.peer())240+ }241+ }242+ return out243+}244+245+func (s *Seat) peer() Peer {246+ return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined}247+}248+249+// Others is how many others are in the room. Call it in a turn.250+func (s *Seat) Others() int { return len(s.room.seats) - 1 }251+252+// Marks are the operations since seq, oldest first, whoever made them.253+// Call it in a turn.254+func (s *Seat) Marks(since uint64) []Mark {255+ m := s.room.marks256+ i, _ := slices.BinarySearchFunc(m, since+1, func(x Mark, seq uint64) int { return int(x.Seq) - int(seq) })257+ return m[i:]258+}259+260+// Last is the latest operation's number. Call it in a turn.261+func (s *Seat) Last() uint64 { return s.room.seen }262+263+// Mode is who may edit the room's workbook.264+func (s *Seat) Mode() Mode { return s.room.mode }265+266+// Writing reports whether the participant may edit: in an Edit room267+// everyone, in a View room the writer.268+func (s *Seat) Writing() bool { return s.room.mode == Edit || s.room.writer == s.id }269+270+// Writer is the writer's name in a View room, "" in an Edit room.271+func (s *Seat) Writer() string {272+ if s.room.mode == Edit {273+ return ""274+ }275+ if w := s.room.seat(s.room.writer); w != nil {276+ return w.name277+ }278+ return ""279+}280+281+// HandTo hands the writer's role to the participant with id, reporting282+// false when this one doesn't write or there's no such participant.283+// Call it in a turn.284+func (s *Seat) HandTo(id int) bool {285+ r := s.room286+ if r.mode != View || r.writer != s.id || r.seat(id) == nil {287+ return false288+ }289+ r.writer, s.moved = id, true290+ return true291+}292+293+// Saved is the room's file as last saved or opened. Call it in a turn.294+func (s *Seat) Saved() Saved { return s.room.saved }295+296+// SetSaved records a save, or the file the room was opened from, for297+// every session. Call it in a turn.298+func (s *Seat) SetSaved(v Saved) {299+ s.room.saved, s.moved = v, true300+ if v.Key != "" && v.Key != s.room.key {301+ s.room.alias = v.Key302+ }303+}304+305+// Value is the room's value for key, made by make the first time: state306+// every participant shares, such as the notebook cells running. Call it307+// in a turn.308+func (s *Seat) Value(key string, make func() any) any {309+ r := s.room310+ if r.values == nil {311+ r.values = map[string]any{}312+ }313+ v, ok := r.values[key]314+ if !ok {315+ v = make()316+ r.values[key] = v317+ }318+ return v319+}320+321+// Keeper reports whether the participant keeps what the room runs on322+// its own (linked files followed, notebook cells running): the one who323+// has been there longest. Call it in a turn.324+func (s *Seat) Keeper() bool { return len(s.room.seats) > 0 && s.room.seats[0] == s }325+326+// Post leaves msg for the keeper, whoever it is when it takes it: what327+// the room runs reports back this way rather than to the participant328+// that started it, who may have left. It may be called from any329+// goroutine, outside a turn.330+func (s *Seat) Post(msg any) {331+ r := s.room332+ r.mu.Lock()333+ defer r.mu.Unlock()334+ if r.closed || len(r.seats) == 0 {335+ return336+ }337+ r.inbox = append(r.inbox, msg)338+ r.seats[0].p.Notify()339+}340+341+// Take is what was posted for the keeper, if this is the keeper. Call342+// it in a turn.343+func (s *Seat) Take() []any {344+ if !s.Keeper() || len(s.room.inbox) == 0 {345+ return nil346+ }347+ in := s.room.inbox348+ s.room.inbox = nil349+ return in350+}351+352+// Leave gives up the seat, reporting whether it was the last: the room353+// closes then, and its workbook is the participant's alone.354+func (s *Seat) Leave() (last bool) {355+ r := s.room356+ r.mu.Lock()357+ if s.left {358+ r.mu.Unlock()359+ return false360+ }361+ s.left = true362+ i := slices.Index(r.seats, s)363+ r.seats = slices.Delete(r.seats, i, i+1)364+ empty := len(r.seats) == 0365+ if !empty {366+ if r.mode == View && r.writer == s.id {367+ r.writer = r.seats[0].id368+ }369+ r.notify(nil)370+ }371+ r.mu.Unlock()372+ if empty {373+ return r.reg.close(r)374+ }375+ return false376+}@@ -0,0 +1,169 @@
1+package room2+3+import (4+ "fmt"5+ "sync"6+ "sync/atomic"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// who is a participant counting the times it was told the room changed.13+type who struct {14+ name string15+ kicks atomic.Int6416+}17+18+func (w *who) Name() string { return w.name }19+func (w *who) Notify() { w.kicks.Add(1) }20+21+func addr(s string) sheet.Addr {22+ a, _ := sheet.ParseAddr(s)23+ return a24+}25+26+func newBook() *sheet.Workbook { return sheet.New().Book() }27+28+// Sessions opening the same key share one workbook: each turn's steps29+// are its author's, the others are told, and the room remembers who30+// changed which cells, in order.31+func TestRoomSharesOneWorkbook(t *testing.T) {32+ g := NewRegistry(Edit)33+ ann, bob := &who{name: "ann"}, &who{name: "bob"}34+ a, created := g.Join("/srv/budget.012", ann, newBook)35+ if !created {36+ t.Fatal("the first join didn't open the room")37+ }38+ b, created := g.Join("/srv/budget.012", bob, func() *sheet.Workbook { t.Fatal("opened twice"); return nil })39+ if created || a.Book() != b.Book() || a.Color() == b.Color() {40+ t.Fatalf("created %v, same book %v, colors %d %d", created, a.Book() == b.Book(), a.Color(), b.Color())41+ }42+ before := bob.kicks.Load()43+ a.Do(func(w *sheet.Workbook) { w.Sheets()[0].Set(addr("A1"), "ann's") })44+ if bob.kicks.Load() == before {45+ t.Error("bob wasn't told of ann's edit")46+ }47+ b.Do(func(w *sheet.Workbook) { w.Sheets()[0].Set(addr("B1"), "bob's") })48+ var marks []Mark49+ a.Do(func(*sheet.Workbook) { marks = a.Marks(0) })50+ if len(marks) != 2 || marks[0].Name != "ann" || marks[1].Name != "bob" || marks[1].Rect.From != addr("B1") || marks[0].Seq >= marks[1].Seq {51+ t.Fatalf("marks %+v", marks)52+ }53+ // Undo is each author's own.54+ a.Do(func(w *sheet.Workbook) { w.Undo() })55+ s := a.Book().Sheets()[0]56+ if s.Filled(addr("A1")) || s.Value(addr("B1")).Str != "bob's" {57+ t.Errorf("after ann's undo: A1 %v, B1 %v", s.Value(addr("A1")), s.Value(addr("B1")))58+ }59+}60+61+// Presence reaches the others; a turn that changes nothing tells no one.62+func TestRoomPresence(t *testing.T) {63+ g := NewRegistry(Edit)64+ ann, bob := &who{name: "ann"}, &who{name: "bob"}65+ a, _ := g.Join("@standup", ann, newBook)66+ b, _ := g.Join("@standup", bob, nil)67+ before := bob.kicks.Load()68+ a.Do(func(*sheet.Workbook) {})69+ if bob.kicks.Load() != before {70+ t.Error("told of nothing")71+ }72+ a.Do(func(w *sheet.Workbook) { a.Move(Presence{Sheet: w.Sheets()[0], Cursor: addr("C3")}) })73+ if bob.kicks.Load() == before {74+ t.Error("not told ann moved")75+ }76+ var peers []Peer77+ b.Do(func(*sheet.Workbook) { peers = b.Peers() })78+ if len(peers) != 1 || peers[0].Name != "ann" || peers[0].Presence.Cursor != addr("C3") {79+ t.Errorf("peers %+v", peers)80+ }81+}82+83+// In a View room one writes: the first in, until they hand it over or84+// leave.85+func TestRoomWriter(t *testing.T) {86+ g := NewRegistry(View)87+ a, _ := g.Join("k", &who{name: "ann"}, newBook)88+ b, _ := g.Join("k", &who{name: "bob"}, nil)89+ c, _ := g.Join("k", &who{name: "cy"}, nil)90+ a.Do(func(*sheet.Workbook) {91+ if !a.Writing() || b.Writing() || a.Writer() != "ann" {92+ t.Errorf("writing: ann %v, bob %v", a.Writing(), b.Writing())93+ }94+ if b.HandTo(c.ID()) {95+ t.Error("bob handed over what he doesn't hold")96+ }97+ if !a.HandTo(b.ID()) || !b.Writing() || a.Writing() {98+ t.Error("ann didn't hand over to bob")99+ }100+ })101+ b.Leave()102+ a.Do(func(*sheet.Workbook) {103+ if !a.Writing() {104+ t.Error("the writer left, and ann, there longest, doesn't write")105+ }106+ })107+}108+109+// What is posted goes to the keeper, the one there longest, and to the110+// next one once the keeper leaves; the last to leave closes the room.111+func TestRoomKeeperAndLeaving(t *testing.T) {112+ g := NewRegistry(Edit)113+ ann, bob := &who{name: "ann"}, &who{name: "bob"}114+ a, _ := g.Join("k", ann, newBook)115+ b, _ := g.Join("k", bob, nil)116+ b.Post("done")117+ var got []any118+ b.Do(func(*sheet.Workbook) { got = b.Take() })119+ if got != nil {120+ t.Errorf("bob took %v", got)121+ }122+ if a.Leave() {123+ t.Fatal("ann was last")124+ }125+ b.Do(func(*sheet.Workbook) { got = b.Take() })126+ if len(got) != 1 || got[0] != "done" {127+ t.Errorf("bob, keeper now, took %v", got)128+ }129+ if !g.Has("k") || !b.Leave() || g.Has("k") || g.Rooms() != 0 {130+ t.Error("the last to leave didn't close the room")131+ }132+ // A save under another name makes the room that file's too.133+ c, _ := g.Join("@r", &who{name: "cy"}, newBook)134+ c.Do(func(*sheet.Workbook) { c.SetSaved(Saved{Name: "r.012", Key: "/srv/r.012"}) })135+ if !g.Has("/srv/r.012") || g.Rooms() != 1 {136+ t.Error("saving didn't alias the room")137+ }138+}139+140+// Many participants taking turns at once: every step lands, in one141+// order, and the race detector finds nothing.142+func TestRoomTurnsAtOnce(t *testing.T) {143+ g := NewRegistry(Edit)144+ var wg sync.WaitGroup145+ const n, edits = 6, 50146+ for i := range n {147+ s, _ := g.Join("k", &who{name: fmt.Sprint(i)}, newBook)148+ wg.Add(1)149+ go func() {150+ defer wg.Done()151+ for j := range edits {152+ s.Do(func(w *sheet.Workbook) {153+ w.Sheets()[0].Set(sheet.Addr{Col: i, Row: j}, fmt.Sprint(j))154+ s.Move(Presence{Cursor: sheet.Addr{Col: i, Row: j}})155+ })156+ }157+ }()158+ }159+ wg.Wait()160+ s, _ := g.Join("k", &who{name: "last"}, nil)161+ s.Do(func(w *sheet.Workbook) {162+ if got := w.Sheets()[0].Len(); got != n*edits {163+ t.Errorf("%d cells", got)164+ }165+ if s.Last() != n*edits {166+ t.Errorf("%d operations", s.Last())167+ }168+ })169+}@@ -54,6 +54,7 @@ type ending struct {
5454 kept string // the recovery file, relative to the served directory5555 keptFor string // the file name it was kept for, "" for a book never saved5656 err error // keeping it failed57+ others string // who still has the shared workbook open, "" when nobody5758 }5859 5960 // keep writes the unsaved work of the program g ran to a recovery file@@ -80,6 +81,8 @@ func (e ending) tell(w io.Writer, idle time.Duration, root confine.Root) {
8081 fmt.Fprint(w, "012: stopped on an internal error; the server's log has a report\r\n")8182 }8283 switch {84+ case e.others != "" && (e.idle || e.crash != nil):85+ fmt.Fprintf(w, "012: the workbook stays open with %s\r\n", e.others)8386 case e.err != nil:8487 fmt.Fprintf(w, "012: couldn't keep the unsaved changes: %s\r\n", root.Scrub(e.err.Error()))8588 case e.kept != "" && e.keptFor == "":@@ -38,6 +38,10 @@ type Options struct {
3838 // MaxSessions is how many sessions may run at once; more are turned3939 // away.4040 MaxSessions int41+ // Share is whether sessions opening the same file share its42+ // workbook: "edit" (everyone edits), "view" (one writes, the others43+ // follow) or "off" (a copy each); "" is off.44+ Share string4145 }4246 4347 // Default values.@@ -51,7 +55,7 @@ const (
5155 // the loopback address, keys from ~/.ssh/authorized_keys and the host key5256 // in 012's config directory.5357 func Defaults() Options {54- o := Options{Dir: ".", Listen: DefaultListen, IdleTimeout: DefaultIdleTimeout, MaxSessions: DefaultMaxSessions}58+ o := Options{Dir: ".", Listen: DefaultListen, IdleTimeout: DefaultIdleTimeout, MaxSessions: DefaultMaxSessions, Share: "edit"}5559 if home, err := os.UserHomeDir(); err == nil {5660 o.AuthorizedKeys = filepath.Join(home, ".ssh", "authorized_keys")5761 }@@ -73,6 +77,8 @@ func (o Options) validate() error {
7377 return fmt.Errorf("max sessions is %d, it must be at least 1", o.MaxSessions)7478 case o.IdleTimeout < 0:7579 return errors.New("the idle timeout can't be negative")80+ case o.Share != "" && o.Share != "edit" && o.Share != "view" && o.Share != "off":81+ return fmt.Errorf("share is %q: edit, view or off", o.Share)7682 }7783 return nil7884 }@@ -18,6 +18,7 @@ import (
1818 1919 "github.com/FelineStateMachine/012/internal/confine"2020 "github.com/FelineStateMachine/012/internal/jev"21+ "github.com/FelineStateMachine/012/internal/room"2122 "github.com/FelineStateMachine/012/internal/sheet"2223 "github.com/FelineStateMachine/012/internal/telemetry"2324 "github.com/FelineStateMachine/012/internal/ui"@@ -36,6 +37,7 @@ type Server struct {
3637 hostPub gossh.PublicKey3738 slots chan struct{} // one per running session3839 active atomic.Int6440+ rooms *room.Registry // the shared workbooks; nil with sharing off3941 4042 // stop is closed when the server shuts down, ending every session,4143 // which keeps its unsaved work first; running counts the sessions@@ -105,6 +107,12 @@ func New(o Options, d Deps) (*Server, error) {
105107 }106108 s := &Server{opts: o, root: root, log: d.Log, jev: d.JEV, hostPub: signer.PublicKey(), slots: make(chan struct{}, o.MaxSessions),107109 stop: make(chan struct{})}110+ switch o.Share {111+ case "edit":112+ s.rooms = room.NewRegistry(room.Edit)113+ case "view":114+ s.rooms = room.NewRegistry(room.View)115+ }108116 s.ssh = &ssh.Server{109117 Addr: o.Listen,110118 Handler: s.session,@@ -303,6 +311,9 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
303311 m.TraceUnder(parent)304312 env := append(sess.Environ(), "TERM="+pty.Term)305313 m.Serve(s.root, env)314+ if s.rooms != nil {315+ m.ShareRooms(s.rooms, sess.User())316+ }306317 m.OpenOnStart(name)307318 if s.jev != nil {308319 // Each session has its own cache: it queues that session's@@ -311,12 +322,21 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
311322 }312323 in := newActivity(sess)313324 g := ui.Guard(m)314- p := tea.NewProgram(g, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)325+ var model tea.Model = g326+ var shared *ui.Shared327+ if s.rooms != nil {328+ shared = ui.InRooms(g)329+ model = shared330+ }331+ p := tea.NewProgram(model, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)315332 316333 // The connection's context ends when the client goes away; cancel317334 // stops the goroutines below when the program quits first.318335 ctx, cancel := context.WithCancel(sess.Context())319336 defer cancel()337+ if shared != nil {338+ shared.Attach(ctx, p)339+ }320340 go resize(ctx, p, winch)321341 go func() {322342 select {@@ -344,7 +364,13 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
344364 // The report is the server's log: the operator's to read.345365 s.log.Error("session panic", "where", c.Where, "panic", fmt.Sprint(c.Value), "stack", string(c.Stack))346366 }347- end.keep(g)367+ // A shared workbook stays with those still in its room; the last368+ // one out keeps its unsaved work.369+ _, end.others = g.Model().Others()370+ if g.Model().LeaveRoom() {371+ end.others = ""372+ end.keep(g)373+ }348374 return end349375 }350376 @@ -0,0 +1,62 @@
1+package serve2+3+import (4+ "context"5+ "io"6+ "path/filepath"7+ "strings"8+ "testing"9+ "time"10+)11+12+// Two sessions opening one file share its workbook: what one types the13+// other shows at once, the first to quit leaves it to the other without14+// asking, and stopping the server keeps the unsaved work once.15+func TestSessionsShareAFile(t *testing.T) {16+ key := newKey(t)17+ srv, addr, dir := testServer(t, nil, key)18+ saveSheet(t, filepath.Join(dir, "book.012"), "saved")19+ c, err := dial(addr, key, srv.HostKey())20+ if err != nil {21+ t.Fatal(err)22+ }23+ defer c.Close()24+ var terms []*term25+ for range 3 {26+ tm, err := openExec(t, c, "book.012")27+ if err != nil {28+ t.Fatal(err)29+ }30+ tm.waitFor(t, "saved")31+ terms = append(terms, tm)32+ }33+ a, b, cy := terms[0], terms[1], terms[2]34+ io.WriteString(a.stdin, "\x1b[Bfrom a\r")35+ b.waitFor(t, "from a", "modified")36+ cy.waitFor(t, "from a")37+38+ io.WriteString(b.stdin, "\x11") // Ctrl+Q: a and cy still have it39+ b.waitClosed(t)40+ a.waitFor(t, "from a")41+42+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)43+ defer cancel()44+ if err := srv.Shutdown(ctx); err != nil {45+ t.Fatal(err)46+ }47+ a.waitClosed(t)48+ cy.waitClosed(t)49+ files, _ := filepath.Glob(filepath.Join(dir, ".012-recovery", "*"))50+ if len(files) != 1 {51+ t.Fatalf("recovery files %v; want one for the shared workbook", files)52+ }53+ kept := 054+ for _, tm := range []*term{a, cy} {55+ if strings.Contains(tm.output(), "unsaved changes kept in") {56+ kept++57+ }58+ }59+ if kept != 1 {60+ t.Errorf("%d sessions said they kept the changes", kept)61+ }62+}@@ -27,7 +27,7 @@ import (
2727 2828 func BenchmarkSessions(b *testing.B) {2929 for _, n := range []int{10, 50} {30- for _, shape := range []string{"empty", "dense-1000x26"} {30+ for _, shape := range []string{"empty", "dense-1000x26", "dense-1000x26-shared"} {3131 b.Run(fmt.Sprintf("sessions=%d/%s", n, shape), func(b *testing.B) { benchSessions(b, n, shape) })3232 }3333 }@@ -35,7 +35,13 @@ func BenchmarkSessions(b *testing.B) {
3535 3636 func benchSessions(b *testing.B, n int, shape string) {3737 key := newKey(b)38- srv, addr, dir := testServer(b, func(o *Options) { o.MaxSessions, o.IdleTimeout = n, 0 }, key)38+ // A copy of the sheet each, but for the shared shape: one workbook39+ // in one room, every key moving a pointer the others draw.40+ share := "off"41+ if shape == "dense-1000x26-shared" {42+ share = "edit"43+ }44+ srv, addr, dir := testServer(b, func(o *Options) { o.MaxSessions, o.IdleTimeout, o.Share = n, 0, share }, key)3945 if shape != "empty" {4046 writeDense(b, filepath.Join(dir, "dense.012"))4147 }@@ -0,0 +1,374 @@
1+package sheet2+3+import "slices"4+5+// Authors: a workbook several people edit at once (012 serve's shared6+// rooms) is still changed through the one mutation path, Batch and the7+// history's steps, in the one order the server runs them in. Each step8+// records its author (SetAuthor names who is acting) and a sequence9+// number, and the steps made, undone and redone are a stream of10+// operations (Op) the room sends every participant.11+//12+// With the history shared (ShareHistory), Undo and Redo take back only13+// the author's own steps: Undo reverts the author's latest step even14+// when others' steps came after it, as long as none of those changed15+// what it changed (the same cells, widths, heights, line formats or16+// names; the same sheet's charts, pivot, rules, regions or view; the17+// sheet list, the settings or the macros), and none of them, nor it,18+// inserted or deleted rows or columns on a sheet the other changed. The19+// step then leaves the middle of the history, and the later steps stay20+// as they are, their before-images still true: they touched none of21+// what it restores. Otherwise Undo refuses, and UndoBlocked says whose22+// step is in the way. Redo takes back the author's latest undo on the23+// same terms, against the steps made since. Formulas that read what an24+// undone step changed recalculate; entries typed after it that don't25+// overlap it stay as typed.26+27+// OpKind is what an operation did to the history.28+type OpKind int29+30+const (31+ OpDo OpKind = iota // a step made32+ OpUndo // a step undone33+ OpRedo // a step redone34+)35+36+// Op is one operation in the order the workbook ran it: a step made,37+// undone or redone, by its author, with what the step says of itself.38+type Op struct {39+ Seq uint64 // its place in the order, from 140+ Author int41+ Kind OpKind42+ Change43+}44+45+// opLogSize is how many of the latest operations the workbook keeps for46+// OpsSince.47+const opLogSize = 25648+49+// Blocked is a later step that keeps an author's step from being50+// undone or redone: whose it is, and what it did.51+type Blocked struct {52+ Author int53+ Label string54+}55+56+// SetAuthor names who makes the steps that follow; 0 is nobody in57+// particular, as in a workbook only one person edits.58+func (w *Workbook) SetAuthor(a int) { w.author = a }59+60+// Author is who makes the steps that follow (SetAuthor).61+func (w *Workbook) Author() int { return w.author }62+63+// ShareHistory has undo and redo take back only each author's own64+// steps, from now on.65+func (w *Workbook) ShareHistory() { w.hist.shared = true }66+67+// Version changes whenever the workbook changes: a step made, undone or68+// redone, a recalculation, rows arriving at a region.69+func (w *Workbook) Version() uint64 { return w.gen + w.hist.seq }70+71+// OpsSince is the operations after seq, the last opLogSize at most, and72+// the sequence number of the latest.73+func (w *Workbook) OpsSince(seq uint64) ([]Op, uint64) {74+ h := &w.hist75+ i, _ := slices.BinarySearchFunc(h.log, seq+1, func(o Op, s uint64) int { return int(o.Seq) - int(s) })76+ return slices.Clone(h.log[i:]), h.seq77+}78+79+// Checkpoint marks where the history stands, for UndoTo.80+func (w *Workbook) Checkpoint() int { return int(w.hist.seq) }81+82+// UndoTo undoes the current author's steps made after checkpoint c, as83+// cancelling a dialog does, whatever others changed meanwhile; it stops84+// at a step it can't undo.85+func (w *Workbook) UndoTo(c int) {86+ h := &w.hist87+ for i := h.mine(w.author); i >= 0 && h.undo[i].seq > uint64(c); i = h.mine(w.author) {88+ if _, ok := w.Undo(); !ok {89+ return90+ }91+ }92+}93+94+// LastOp is the latest operation's sequence number, 0 before any.95+func (w *Workbook) LastOp() uint64 { return w.hist.seq }96+97+// EndStep closes the step left open (Begin) by a participant who is98+// gone, as if they had ended it: what it did stays, one step.99+func (w *Workbook) EndStep() {100+ for w.hist.depth > 0 {101+ w.finish()102+ }103+}104+105+// InStep reports whether a step is open (Begin, a macro running), and106+// whose it is.107+func (w *Workbook) InStep() (bool, int) {108+ if w.hist.open == nil {109+ return false, 0110+ }111+ return true, w.hist.open.author112+}113+114+// logOp notes an operation in the order.115+func (w *Workbook) logOp(kind OpKind, author int, c Change) uint64 {116+ h := &w.hist117+ h.seq++118+ h.log = append(h.log, Op{Seq: h.seq, Author: author, Kind: kind, Change: c})119+ if len(h.log) > opLogSize {120+ h.log = slices.Delete(h.log, 0, len(h.log)-opLogSize)121+ }122+ return h.seq123+}124+125+// mine is the index in the undo stack of the author's latest step, or126+// -1; with the history unshared, the top's.127+func (h *history) mine(author int) int {128+ if !h.shared {129+ return len(h.undo) - 1130+ }131+ for i := len(h.undo) - 1; i >= 0; i-- {132+ if h.undo[i].author == author {133+ return i134+ }135+ }136+ return -1137+}138+139+// mineRedo is the index in the redo stack of the author's latest undo.140+func (h *history) mineRedo(author int) int {141+ if !h.shared {142+ return len(h.redo) - 1143+ }144+ for i := len(h.redo) - 1; i >= 0; i-- {145+ if h.redo[i].author == author {146+ return i147+ }148+ }149+ return -1150+}151+152+// UndoBlocked reports whether the current author's latest step can't be153+// undone, and the first later step in the way.154+func (w *Workbook) UndoBlocked() (Blocked, bool) {155+ h := &w.hist156+ i := h.mine(w.author)157+ if i < 0 {158+ return Blocked{}, false159+ }160+ return h.blocking(h.undo[i], h.undo[i+1:], 0)161+}162+163+// RedoBlocked reports whether the current author's latest undo can't be164+// redone, and the first step made since that's in the way.165+func (w *Workbook) RedoBlocked() (Blocked, bool) {166+ h := &w.hist167+ i := h.mineRedo(w.author)168+ if i < 0 {169+ return Blocked{}, false170+ }171+ return h.blocking(h.redo[i], h.undo, h.redo[i].seq)172+}173+174+// blocking is the first of later, made after seq by another author,175+// that overlaps st. The author's own steps after one of theirs are176+// redos of their own undos, which came back in order; with the history177+// unshared nothing blocks.178+func (h *history) blocking(st *step, later []*step, seq uint64) (Blocked, bool) {179+ if !h.shared {180+ return Blocked{}, false181+ }182+ for _, o := range later {183+ if o.seq > seq && o.author != st.author && st.overlaps(o) {184+ return Blocked{Author: o.author, Label: o.label}, true185+ }186+ }187+ return Blocked{}, false188+}189+190+// takeUndo removes the author's latest step from the undo stack, when191+// nothing later is in the way, giving the steps after it new IDs: the192+// states they lead to are new.193+func (h *history) takeUndo(author int) *step {194+ i := h.mine(author)195+ if i < 0 {196+ return nil197+ }198+ if _, blocked := h.blocking(h.undo[i], h.undo[i+1:], 0); blocked {199+ return nil200+ }201+ st := h.undo[i]202+ h.undo = slices.Delete(h.undo, i, i+1)203+ h.bytes -= st.bytes204+ for _, later := range h.undo[i:] {205+ h.lastID++206+ later.id = h.lastID207+ }208+ return st209+}210+211+// takeRedo removes the author's latest undo from the redo stack, when212+// no step made since is in the way.213+func (h *history) takeRedo(author int) *step {214+ i := h.mineRedo(author)215+ if i < 0 {216+ return nil217+ }218+ if _, blocked := h.blocking(h.redo[i], h.undo, h.redo[i].seq); blocked {219+ return nil220+ }221+ st := h.redo[i]222+ h.redo = slices.Delete(h.redo, i, i+1)223+ return st224+}225+226+// dropRedo forgets the author's undos, which a new step of theirs ends,227+// or everyone's when the history isn't shared.228+func (h *history) dropRedo(author int) {229+ if !h.shared {230+ h.redo = nil231+ return232+ }233+ h.redo = slices.DeleteFunc(h.redo, func(st *step) bool { return st.author == author })234+}235+236+// recordShift notes that the open step moves the sheet's cells by237+// inserting or deleting lines, which conflicts with any other step on238+// the sheet.239+func (s *Sheet) recordShift() {240+ if st := s.wb.hist.open; st != nil {241+ if st.shifts == nil {242+ st.shifts = map[*Sheet]bool{}243+ }244+ st.shifts[s] = true245+ }246+}247+248+// overlaps reports whether st and o change any of the same things, so249+// one can't be undone past the other (see Authors above).250+func (st *step) overlaps(o *step) bool {251+ if st.sheets != nil || o.sheets != nil || st.settings != nil && o.settings != nil || st.macros != nil && o.macros != nil {252+ return true253+ }254+ for s := range st.shifts {255+ if o.touches(s) {256+ return true257+ }258+ }259+ for s := range o.shifts {260+ if st.touches(s) {261+ return true262+ }263+ }264+ return st.sameCells(o) || sharesKey(st.widths, o.widths) || sharesKey(st.heights, o.heights) ||265+ sharesKey(st.lines, o.lines) || sharesKey(st.names, o.names) || sharesKey(st.views, o.views) ||266+ sharesKey(st.charts, o.charts) || sharesKey(st.pivots, o.pivots) || sharesKey(st.rules, o.rules) ||267+ sharesKey(st.regions, o.regions)268+}269+270+// sameCells reports whether the steps changed any of the same cells.271+func (st *step) sameCells(o *step) bool {272+ for s, img := range st.cells {273+ other := o.cells[s]274+ if other == nil {275+ continue276+ }277+ if img.len() > other.len() {278+ img, other = other, img279+ }280+ if img.anyIn(other) {281+ return true282+ }283+ }284+ return false285+}286+287+// touches reports whether the step changed anything of sheet s.288+func (st *step) touches(s *Sheet) bool {289+ if st.cells[s] != nil || st.views[s] != nil || st.shifts[s] {290+ return true291+ }292+ for _, m := range []map[*Sheet]bool{keysOf(st.charts), keysOf(st.pivots), keysOf(st.rules), keysOf(st.regions)} {293+ if m[s] {294+ return true295+ }296+ }297+ for k := range st.widths {298+ if k.s == s {299+ return true300+ }301+ }302+ for k := range st.heights {303+ if k.s == s {304+ return true305+ }306+ }307+ for k := range st.lines {308+ if k.s == s {309+ return true310+ }311+ }312+ return false313+}314+315+// keysOf is a map's sheets as a set.316+func keysOf[V any](m map[*Sheet]V) map[*Sheet]bool {317+ out := make(map[*Sheet]bool, len(m))318+ for s := range m {319+ out[s] = true320+ }321+ return out322+}323+324+// sharesKey reports whether two maps have a key in common.325+func sharesKey[K comparable, V1, V2 any](a map[K]V1, b map[K]V2) bool {326+ if len(a) > len(b) {327+ for k := range b {328+ if _, ok := a[k]; ok {329+ return true330+ }331+ }332+ return false333+ }334+ for k := range a {335+ if _, ok := b[k]; ok {336+ return true337+ }338+ }339+ return false340+}341+342+// anyIn reports whether img holds a before-image of any cell other343+// holds one of.344+func (img *image) anyIn(other *image) bool {345+ found := false346+ img.addrs(func(a Addr) bool {347+ found = other.seen(a)348+ return !found349+ })350+ return found351+}352+353+// addrs calls fn with the address of every cell the image holds, until354+// fn returns false.355+func (img *image) addrs(fn func(Addr) bool) {356+ for _, ic := range img.small {357+ if !fn(ic.a) {358+ return359+ }360+ }361+ st := img.tables()362+ more := true363+ for _, occ := range []*occupancy{&st.stored, &img.blank} {364+ for _, col := range occ.colIDs {365+ occ.colScan(col, 0, MaxRows-1, func(row int) bool {366+ more = fn(Addr{Col: col, Row: row})367+ return more368+ })369+ if !more {370+ return371+ }372+ }373+ }374+}@@ -0,0 +1,171 @@
1+package sheet2+3+import (4+ "testing"5+)6+7+// sharedBook is a workbook with its history shared, as a room's is.8+func sharedBook() (*Workbook, *Sheet) {9+ s := New()10+ w := s.Book()11+ w.ShareHistory()12+ return w, s13+}14+15+// as makes one step as author a.16+func as(w *Workbook, a int, fn func()) {17+ w.SetAuthor(a)18+ fn()19+}20+21+// Operations are numbered in the order they run, whoever runs them, and22+// OpsSince hands them out in that order.23+func TestOpsInOrder(t *testing.T) {24+ w, s := sharedBook()25+ as(w, 1, func() { s.Set(at("A1"), "1") })26+ as(w, 2, func() { s.Set(at("B1"), "2") })27+ as(w, 1, func() { w.Undo() })28+ ops, last := w.OpsSince(0)29+ if last != 3 || len(ops) != 3 {30+ t.Fatalf("ops %+v, last %d", ops, last)31+ }32+ want := []struct {33+ author int34+ kind OpKind35+ label string36+ }{{1, OpDo, "edit A1"}, {2, OpDo, "edit B1"}, {1, OpUndo, "edit A1"}}37+ for i, o := range ops {38+ if o.Seq != uint64(i+1) || o.Author != want[i].author || o.Kind != want[i].kind || o.Label != want[i].label {39+ t.Errorf("op %d: %+v, want %+v", i, o, want[i])40+ }41+ }42+ if more, _ := w.OpsSince(2); len(more) != 1 || more[0].Kind != OpUndo {43+ t.Errorf("since 2: %+v", more)44+ }45+}46+47+// Undo takes back only the author's own latest step, from under later48+// steps of others that changed other cells; those stay.49+func TestUndoIsPerAuthor(t *testing.T) {50+ w, s := sharedBook()51+ as(w, 1, func() { s.Set(at("A1"), "mine") })52+ as(w, 2, func() { s.Set(at("B1"), "theirs") })53+ as(w, 2, func() { s.Set(at("B2"), "=A1") })54+ before := w.StateID()55+ w.SetAuthor(1)56+ if w.UndoLabel() != "edit A1" {57+ t.Errorf("author 1 would undo %q", w.UndoLabel())58+ }59+ if _, ok := w.Undo(); !ok {60+ t.Fatal("author 1's undo refused")61+ }62+ if s.Filled(at("A1")) || s.Value(at("B1")).Str != "theirs" {63+ t.Errorf("A1 %v, B1 %v", s.Value(at("A1")), s.Value(at("B1")))64+ }65+ if v := s.Value(at("B2")); v.Str == "mine" {66+ t.Errorf("B2, reading A1, is %v", v)67+ }68+ if w.StateID() == before {69+ t.Error("an undo from under others' steps kept the state's ID")70+ }71+ if w.CanUndo() {72+ t.Error("author 1 has nothing left to undo")73+ }74+ w.SetAuthor(2)75+ if !w.CanUndo() || w.UndoLabel() != "edit B2" {76+ t.Errorf("author 2 would undo %q", w.UndoLabel())77+ }78+ // Redo is per author too.79+ w.SetAuthor(1)80+ if !w.CanRedo() {81+ t.Fatal("author 1 can't redo")82+ }83+ w.Redo()84+ if s.Value(at("A1")).Str != "mine" || s.Value(at("B2")).Str != "mine" {85+ t.Errorf("after redo A1 %v, B2 %v", s.Value(at("A1")), s.Value(at("B2")))86+ }87+ w.SetAuthor(2)88+ if w.CanRedo() {89+ t.Error("author 2 can redo author 1's undo")90+ }91+}92+93+// Undo refuses when a later step of someone else's changed what the94+// step changed, and says whose; once that step is undone, it goes.95+func TestUndoRefusesOverlaps(t *testing.T) {96+ w, s := sharedBook()97+ as(w, 1, func() { s.Set(at("A1"), "first") })98+ as(w, 2, func() { s.Set(at("A1"), "second") })99+ w.SetAuthor(1)100+ b, blocked := w.UndoBlocked()101+ if !blocked || b.Author != 2 || b.Label != "edit A1" {102+ t.Fatalf("blocked %v by %+v", blocked, b)103+ }104+ if _, ok := w.Undo(); ok || s.Value(at("A1")).Str != "second" {105+ t.Fatalf("undo went ahead: A1 %v", s.Value(at("A1")))106+ }107+ as(w, 2, func() { w.Undo() })108+ w.SetAuthor(1)109+ if _, blocked := w.UndoBlocked(); blocked {110+ t.Fatal("still blocked")111+ }112+ w.Undo()113+ if s.Filled(at("A1")) {114+ t.Errorf("A1 %v", s.Value(at("A1")))115+ }116+ // Author 2's redo is now in the way of nothing, and redoes onto117+ // the blank cell what it undid.118+ w.SetAuthor(2)119+ if _, ok := w.Redo(); !ok || s.Value(at("A1")).Str != "second" {120+ t.Errorf("author 2's redo: A1 %v", s.Value(at("A1")))121+ }122+ // Author 1's redo would overwrite it: refused.123+ w.SetAuthor(1)124+ if b, blocked := w.RedoBlocked(); !blocked || b.Author != 2 {125+ t.Errorf("author 1's redo not blocked: %+v", b)126+ }127+}128+129+// Inserting or deleting lines conflicts with any other step on the same130+// sheet, and with none on another.131+func TestUndoRefusesAcrossShifts(t *testing.T) {132+ w, s := sharedBook()133+ s2, _ := w.AddSheet("S2", 1)134+ as(w, 1, func() { s.Set(at("A5"), "x") })135+ as(w, 1, func() { s.InsertRows(2, 1) })136+ as(w, 2, func() { s2.Set(at("A1"), "elsewhere") })137+ w.SetAuthor(1)138+ if _, blocked := w.UndoBlocked(); blocked {139+ t.Fatal("a step on another sheet blocks the insert")140+ }141+ as(w, 2, func() { s.Set(at("Z1"), "far away") })142+ w.SetAuthor(1)143+ if _, blocked := w.UndoBlocked(); !blocked {144+ t.Fatal("a step on the shifted sheet doesn't block the insert")145+ }146+}147+148+// The sheet list conflicts with everything after it.149+func TestUndoRefusesAfterSheetChanges(t *testing.T) {150+ w, s := sharedBook()151+ as(w, 1, func() { w.AddSheet("S2", 1) })152+ as(w, 2, func() { s.Set(at("A1"), "1") })153+ w.SetAuthor(1)154+ if _, blocked := w.UndoBlocked(); !blocked {155+ t.Error("adding a sheet undone past a later step")156+ }157+}158+159+// Unshared, undo takes back the latest step whoever made it, as a160+// workbook one person edits does.161+func TestUndoUnshared(t *testing.T) {162+ s := New()163+ w := s.Book()164+ as(w, 1, func() { s.Set(at("A1"), "1") })165+ as(w, 2, func() { s.Set(at("A1"), "2") })166+ w.SetAuthor(1)167+ w.Undo()168+ if s.Value(at("A1")).Num != 1 {169+ t.Errorf("A1 %v", s.Value(at("A1")))170+ }171+}@@ -35,11 +35,23 @@ type history struct {
3535 // live preview of a column width or row height and its final value3636 // (or its cancellation) are one step. Seal ends the run.3737 mergeWidths bool38+ // shared has undo and redo take back each author's own steps, seq39+ // counts the operations (steps made, undone, redone) and log keeps40+ // the latest: authors.go.41+ shared bool42+ seq uint6443+ log []Op3844 }3945 4046 type step struct {41- id int42- bytes int64 // estimated heap held, set when pushed on the undo stack47+ id int48+ // author made the step; seq is its operation's place in the order,49+ // or for a step on the redo stack, the order's when it was undone.50+ author int51+ seq uint6452+ // shifts are the sheets whose lines the step inserted or deleted.53+ shifts map[*Sheet]bool54+ bytes int64 // estimated heap held, set when pushed on the undo stack4355 // cellBytes is the estimated heap held by cells, kept as they are4456 // recorded so sizing a step doesn't walk them again.4557 cellBytes int64@@ -143,6 +155,7 @@ func (w *Workbook) begin(s *Sheet, label string, focus Rect) {
143155 h := &w.hist144156 if h.depth == 0 {145157 h.open = newStep(label, s, focus)158+ h.open.author = w.author146159 w.structural = false147160 }148161 h.depth++@@ -188,10 +201,11 @@ func (w *Workbook) push(st *step) {
188201 return189202 }190203 h := &w.hist191- h.redo = nil204+ h.dropRedo(st.author)192205 h.lastID++206+ st.seq = w.logOp(OpDo, st.author, st.change())193207 widthOnly := st.widthOnly()194- if top := h.top(); widthOnly && h.mergeWidths && top != nil && top.widthOnly() {208+ if top := h.top(); widthOnly && h.mergeWidths && top != nil && top.widthOnly() && top.author == st.author {195209 h.joinWidths(top, st)196210 return197211 }@@ -200,6 +214,16 @@ func (w *Workbook) push(st *step) {
200214 h.mergeWidths = widthOnly201215 }202216 217+// change is what the step says of itself.218+func (st *step) change() Change {219+ return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: st.macrosOnly()}220+}221+222+// macrosOnly reports whether the step changed nothing but the macros.223+func (st *step) macrosOnly() bool {224+ return st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && len(st.regions) == 0225+}226+203227 // dropUnchanged removes from st what ended the step as it began.204228 func (w *Workbook) dropUnchanged(st *step) {205229 st.dropUnchangedLines()@@ -330,15 +354,16 @@ func (w *Workbook) Redo() (Change, bool) { return w.swap(false) }
330354 // UndoLabel describes the step Undo would revert, e.g. "clear B3:B5",331355 // or "" when there is none.332356 func (w *Workbook) UndoLabel() string {333- if top := w.hist.top(); top != nil {334- return top.label357+ if i := w.hist.mine(w.author); i >= 0 {358+ return w.hist.undo[i].label335359 }336360 return ""337361 }338362 339-// CanUndo and CanRedo report whether there is a step to undo or redo.340-func (w *Workbook) CanUndo() bool { return len(w.hist.undo) > 0 }341-func (w *Workbook) CanRedo() bool { return len(w.hist.redo) > 0 }363+// CanUndo and CanRedo report whether there is a step to undo or redo:364+// with the history shared, one of the current author's.365+func (w *Workbook) CanUndo() bool { return w.hist.mine(w.author) >= 0 }366+func (w *Workbook) CanRedo() bool { return w.hist.mineRedo(w.author) >= 0 }342367 343368 // StateID identifies the workbook's contents in its undo history: undoing344369 // back to a saved state returns the ID it had when saved, so the UI can@@ -368,28 +393,36 @@ func (s *Sheet) ClearHistory() { s.wb.ClearHistory() }
368393 // before-image, and pushes the state it replaced onto the other stack.369394 func (w *Workbook) swap(undo bool) (Change, bool) {370395 h := &w.hist371- from := &h.redo372- if undo {373- from = &h.undo374- }375- if len(*from) == 0 || h.open != nil {396+ if h.open != nil {376397 return Change{}, false377398 }378399 var st *step379- if undo {400+ switch {401+ case undo && h.shared:402+ st = h.takeUndo(w.author)403+ case undo && len(h.undo) > 0:380404 st = h.popUndo()381- } else {382- st = h.redo[len(h.redo)-1]383- h.redo = h.redo[:len(h.redo)-1]405+ case !undo:406+ st = h.takeRedo(w.author)407+ }408+ if st == nil {409+ return Change{}, false384410 }385411 inv, changed := w.restore(st)386412 w.recalcSwapped(changed)413+ inv.author = st.author414+ c := st.change()387415 if undo {416+ inv.seq = w.logOp(OpUndo, st.author, c)388417 h.redo = append(h.redo, inv)389418 } else {419+ inv.seq = w.logOp(OpRedo, st.author, c)420+ if h.shared {421+ h.lastID++422+ inv.id = h.lastID // a new state, after whatever came since423+ }390424 w.pushUndo(inv)391425 }392426 h.mergeWidths = false393- macrosOnly := st.macros != nil && len(st.cells) == 0 && len(st.lines) == 0 && len(st.names) == 0 && len(st.charts) == 0 && len(st.pivots) == 0 && len(st.rules) == 0 && st.sheets == nil && len(st.regions) == 0394- return Change{Label: st.label, Focus: st.focus, Sheet: st.sheet, Tabs: st.sheets != nil, Macros: macrosOnly}, true427+ return c, true395428 }@@ -9,7 +9,7 @@ import "slices"
99 // change taken back inside another's step (Try) restores its own.1010 func (w *Workbook) restore(st *step) (inv *step, changed []loc) {1111 inv = newStep(st.label, st.sheet, st.focus)12- inv.id = st.id12+ inv.id, inv.shifts = st.id, st.shifts1313 // The sheet list goes first, so cells land on attached sheets.1414 if st.sheets != nil {1515 inv.sheets = w.sheetList()@@ -138,6 +138,7 @@ func (s *Sheet) restructure(rows bool, sp formula.Span) {
138138 label += "s"139139 }140140 s.change(label, focus, func() {141+ s.recordShift()141142 s.shiftRegions(rows, sp) // first, so remap doesn't take cells moving where regions were for theirs142143 s.remap(label, focus, cell, rng)143144 s.remapNames(rng)@@ -0,0 +1,131 @@
1+package sheet2+3+import (4+ "bytes"5+ "fmt"6+ "maps"7+ "math/rand/v2"8+ "testing"9+)10+11+// Random edits by several participants: the edits of TestRandomEdits,12+// each made by one of three authors drawn from the seed, interleaved in13+// one workbook whose history is shared, as a room of 012 serve orders14+// them. Then the authors undo, in turns drawn from the seed, each only15+// their own steps, from under the others' where nothing later overlaps16+// them. What must hold:17+//18+// - someone can always undo (the latest step's author, at least), so19+// every step is undone in the end;20+// - an undo that goes ahead, redone at once by its author, gives back21+// exactly the file it undid;22+// - once every step is undone, whatever the order, the workbook is23+// back where it started: the file it saves and every value.24+func TestRandomSharedEdits(t *testing.T) {25+ seeds := max(*randSeeds/4, 1)26+ if testing.Short() {27+ seeds = min(seeds, 20)28+ }29+ for seed := range seeds {30+ rng := rand.New(rand.NewPCG(uint64(seed), 34))31+ data := make([]byte, 64+(*randSteps)*10)32+ for i := range data {33+ data[i] = byte(rng.Uint32())34+ }35+ t.Run(fmt.Sprint(seed), func(t *testing.T) {36+ msg, log := randomSharedEdits(data, *randSteps, rng)37+ if msg != "" {38+ t.Fatalf("%s\nafter %d edits:\n %s", msg, len(log), joinLog(log))39+ }40+ })41+ }42+}43+44+// randAuthors is how many participants edit.45+const randAuthors = 346+47+func randomSharedEdits(data []byte, steps int, rng *rand.Rand) (string, []string) {48+ e := &edits{data: data}49+ r := newRandBook(e)50+ r.wb.ShareHistory()51+ start, startVals, startFeeds := r.save(), bookValues(r.wb), maps.Clone(r.feeds)52+ var log []string53+ for range steps {54+ if e.done() {55+ break56+ }57+ a := 1 + e.n(randAuthors)58+ r.wb.SetAuthor(a)59+ log = append(log, fmt.Sprintf("%d: %s", a, r.edit(e)))60+ feedStale(r.wb, r.feeds)61+ }62+ for undone := 0; ; undone++ {63+ a, ok := r.nextUndo(rng)64+ if !ok {65+ break66+ }67+ r.wb.SetAuthor(a)68+ label := r.wb.UndoLabel()69+ before := r.save()70+ if _, ok := r.wb.Undo(); !ok {71+ return fmt.Sprintf("author %d's undo of %q refused though nothing blocks it", a, label), log72+ }73+ feedStale(r.wb, r.feeds)74+ log = append(log, fmt.Sprintf("%d undoes %q", a, label))75+ if rng.IntN(3) > 0 {76+ continue77+ }78+ if _, ok := r.wb.Redo(); !ok {79+ return fmt.Sprintf("author %d can't redo %q at once", a, label), log80+ }81+ feedStale(r.wb, r.feeds)82+ if got := r.save(); !bytes.Equal(got, before) {83+ return fmt.Sprintf("author %d's redo of %q saves differently:\n%s", a, label, lineDiff(string(before), string(got))), log84+ }85+ r.wb.Undo()86+ feedStale(r.wb, r.feeds)87+ }88+ feedAll(r.wb, startFeeds)89+ feedStale(r.wb, startFeeds)90+ if got := r.save(); !bytes.Equal(got, start) {91+ return "undoing every step saves differently:\n" + lineDiff(string(start), string(got)), log92+ }93+ if d := diffValues(startVals, bookValues(r.wb)); d != "" {94+ return "undoing every step: " + d, log95+ }96+ return "", log97+}98+99+// nextUndo picks an author who can undo a step now, in a turn drawn100+// from rng, or reports false when nobody has a step left. It fails the101+// run when steps are left but nobody can undo one.102+func (r *randBook) nextUndo(rng *rand.Rand) (int, bool) {103+ first := rng.IntN(randAuthors)104+ left := false105+ for i := range randAuthors {106+ a := 1 + (first+i)%randAuthors107+ r.wb.SetAuthor(a)108+ if !r.wb.CanUndo() {109+ continue110+ }111+ left = true112+ if _, blocked := r.wb.UndoBlocked(); !blocked {113+ return a, true114+ }115+ }116+ if left {117+ panic("steps are left that nobody can undo")118+ }119+ return 0, false120+}121+122+func joinLog(log []string) string {123+ var b bytes.Buffer124+ for i, l := range log {125+ if i > 0 {126+ b.WriteString("\n ")127+ }128+ b.WriteString(l)129+ }130+ return b.String()131+}@@ -69,7 +69,7 @@ type chartHost interface {
6969 type chartEditor struct {7070 m chartHost // the model, through what the editor needs of it7171 i int72- start int // the sheet's state before editing, to undo back to72+ start int // the history before editing (Checkpoint), to undo back to7373 before sheet.Chart // the chart when the editor opened, for a macro7474 isNew bool7575 axes bool // showing the axis options@@ -130,10 +130,7 @@ func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {
130130 e.keep()131131 m.selectChart(e.i)132132 case "esc":133- s := m.sheetShown()134- for s.StateID() != e.start && s.CanUndo() {135- s.Undo()136- }133+ m.sheetShown().Book().UndoTo(e.start)137134 m.syncChanged()138135 m.closeOverlay()139136 m.forgetChart()@@ -229,7 +229,7 @@ func (m *Model) answerEditChart(text string) (tea.Cmd, error) {
229229 if i < 0 {230230 return nil, fmt.Errorf("there's no chart on %s", m.sheet.Name())231231 }232- return nil, m.openChartEditor(i, false, m.sheet.StateID()).answer(fields)232+ return nil, m.openChartEditor(i, false, m.book().Checkpoint()).answer(fields)233233 }234234 235235 // answer makes the chart the answer's fields say and closes the editor.@@ -35,7 +35,7 @@ func init() {
3535 answer: (*Model).answerInsertChart},3636 &command{id: "chart.edit", title: "Edit chart", desc: "Change a chart's type, data and labels",3737 enabled: hasCharts, run: func(m *Model) tea.Cmd {38- m.openChartEditor(m.targetChart(), false, m.sheet.StateID())38+ m.openChartEditor(m.targetChart(), false, m.book().Checkpoint())3939 return nil4040 },4141 answer: (*Model).answerEditChart},@@ -95,7 +95,7 @@ func (m *Model) insertChart() tea.Cmd {
9595 m.note = "Select the data to chart first"9696 return nil9797 }98- start := m.sheet.StateID()98+ start := m.book().Checkpoint()9999 c := m.sheet.GuessChart(r)100100 c.W = clamp(newChartW, sheet.MinChartW, max(m.width-m.hdrW()-2, sheet.MinChartW))101101 c.H = clamp(newChartH, sheet.MinChartH, max(m.visibleRows()-1, sheet.MinChartH))@@ -223,7 +223,7 @@ func (m *Model) runCommand(id string) tea.Cmd {
223223 return nil224224 }225225 }226- if c.edits != nil && m.refuseEdit(c.edits(m), c.keepsSpills) {226+ if c.edits != nil && m.refuseEdit(c.edits(m), c.keepsSpills) || edits(c) && !m.mayEdit() {227227 return nil228228 }229229 if m.askCommand(c) {@@ -115,6 +115,22 @@ func (g *Guarded) View() tea.View {
115115 return g.last116116 }117117 118+// do runs fn on the model as Update would, a panic in it stopping the119+// program the same way: for what Shared does to the model between120+// turns.121+func (g *Guarded) do(fn func() tea.Cmd) (cmd tea.Cmd) {122+ if g.crash != nil {123+ return nil124+ }125+ defer func() {126+ if r := recover(); r != nil {127+ g.crash = caught("update", r)128+ cmd = tea.Quit129+ }130+ }()131+ return g.guard(fn())132+}133+118134 // guard wraps cmd so a panic in it comes to Update as a crashMsg, and119135 // so do the commands of a batch it returns. A sequence's (tea.Sequence)120136 // are run by Bubble Tea itself, which catches their panics: see Finish.@@ -145,6 +161,9 @@ func (g *Guarded) guard(cmd tea.Cmd) tea.Cmd {
145161 // caught, or one only Bubble Tea caught (a panic in a sequence's146162 // command), whose stack it printed on the terminal.147163 func (g *Guarded) Finish(runErr error) *Crash {164+ if g.m.share.seat == nil {165+ g.m.closeStreams() // a stream runs until it's stopped; a room's, until the last one leaves (LeaveRoom)166+ }148167 if g.crash == nil && errors.Is(runErr, tea.ErrProgramPanic) {149168 g.crash = &Crash{Where: "a command", Value: "a panic Bubble Tea caught, its stack printed on the terminal"}150169 }@@ -38,7 +38,7 @@ func (m *Model) quit() tea.Cmd {
3838 if m.pipe.on {3939 return m.quitPiped()4040 }41- if !m.changed {41+ if !m.changed || m.shared() { // the others keep the workbook open4242 return m.exit()4343 }4444 m.ask(question{@@ -83,12 +83,18 @@ func init() {
8383 }8484 8585 func (m *Model) undo() tea.Cmd {86+ if m.undoBlocked(false) {87+ return nil88+ }8689 c, ok := m.sheet.Undo()8790 m.afterHistory("Undid", "Nothing to undo", c, ok)8891 return nil8992 }9093 9194 func (m *Model) redo() tea.Cmd {95+ if m.undoBlocked(true) {96+ return nil97+ }9298 c, ok := m.sheet.Redo()9399 m.afterHistory("Redid", "Nothing to redo", c, ok)94100 return nil@@ -51,6 +51,7 @@ func (m *Model) startEntry(md mode, text string) {
5151 }5252 m.mode = md5353 m.entry.home = nil54+ m.share.entryFrom = m.roomLast()5455 m.line.Clear()5556 m.entry.hint, m.entry.assist = "", suggest.List{}5657 m.line.Insert(text)@@ -241,6 +242,7 @@ func (m *Model) commit() bool {
241242 m.cancelEntry()242243 m.clearSelection()243244 m.recordEntry(input, false)245+ m.overwrote()244246 if warn != "" {245247 m.warn = warn246248 }@@ -267,6 +269,9 @@ func (m *Model) cancelEntry() {
267269 func (m *Model) handlePaste(content string) {268270 switch m.mode {269271 case modeReady:272+ if !m.mayEdit() {273+ return274+ }270275 if m.askProtected(pasteTextRange(m.cur, content), func(m *Model) tea.Cmd {271276 m.handlePaste(content)272277 return nil@@ -116,6 +116,9 @@ func (m *Model) openFile(text string) tea.Cmd {
116116 if !ok {117117 return nil118118 }119+ if m.share.reg != nil {120+ return m.openShared(name, p)121+ }119122 return loadCmd(name, p, m.spans.Parent())120123 }121124 @@ -123,11 +126,13 @@ func (m *Model) openFile(text string) tea.Cmd {
123126 // session rather than the sheet carries over: the window, theme, terminal124127 // state, served directory and the JEV connection.125128 func (m *Model) reset(s *sheet.Sheet, filename string) {126- m.stopCells() // the old workbook's cells129+ if !m.shared() {130+ m.stopCells() // the old workbook's cells, unless others share them131+ }127132 m.closeSources()128- nb := nbState{runner: m.nb.runner, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}133+ nb := nbState{runner: m.nb.runner, runs: &nbRuns{}, served: m.nb.served, words: m.nb.words, asked: m.nb.asked, lang: m.nb.lang}129134 defer func() { m.nb = nb; m.bookOpened() }()130- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe,135+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, root: m.root, charts: chartState{last: -1}, prefs: m.prefs, session: m.session, pipe: m.pipe, share: m.share,131136 macros: macroState{machine: m.macros.machine, editor: m.macros.editor}, spans: m.spans}132137 s.Book().SetTrace(m.spans)133138 if m.jev != nil {@@ -349,6 +354,11 @@ func (m *Model) handleSaved(msg savedMsg) tea.Cmd {
349354 m.filename, m.disk = msg.name, msg.stamp350355 m.changed = false351356 m.nb.saved = m.nb.saving357+ if seat := m.share.seat; seat != nil {358+ v := m.savedState()359+ v.Outputs = m.nb.saved360+ seat.SetSaved(v)361+ }352362 m.savedRecovered()353363 if left := m.book().UnsavedOutputs(); len(left) > 0 {354364 m.note = "Saved without the outputs of " + strings.Join(left, ", ") + " (over nu-save-cell-kb or nu-save-notebook-kb): run them to see them again"@@ -364,6 +374,10 @@ func (m *Model) handleLoaded(msg loadedMsg) {
364374 m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))365375 return366376 }377+ if m.share.reg != nil {378+ m.loadedShared(msg)379+ return380+ }367381 m.reset(msg.sheet, msg.name)368382 m.disk = msg.stamp369383 m.offerRecovery(msg.name)@@ -67,6 +67,12 @@ func (m *Model) sourceKey(src sheet.LinkSource) string {
6767 // sources for new ones, none for those gone, a whole read for stale6868 // ones, and the next poll for each that follows.6969 func (m *Model) syncFollowers() tea.Cmd {70+ if seat := m.share.seat; seat != nil && !seat.Keeper() {71+ for _, f := range m.follow.by {72+ m.dropFollower(f) // the room's keeper follows them73+ }74+ return nil75+ }7076 regions := m.book().LinkedRegions()7177 if len(regions) == 0 && len(m.follow.by) == 0 {7278 return nil@@ -162,7 +168,7 @@ func (m *Model) dropFollower(f *follower) {
162168 163169 // pollFollower polls f's source on a goroutine of its own.164170 func pollFollower(f *follower) tea.Cmd {165- f.busy = true171+ f.busy = true // what it finds comes back to this model: see syncFollowers166172 return func() tea.Msg {167173 u, ok := f.src.Poll(context.Background())168174 return followMsg{f, u, ok}@@ -34,6 +34,7 @@ var menuBar = []menuDef{
3434 {title: "File", accel: 'f', items: []menuItem{3535 {cmd: "file.new"}, {cmd: "file.open"}, {cmd: "file.import"}, sep,3636 {cmd: "file.save"}, {cmd: "file.saveas"}, {title: "Download", items: downloadItems()}, sep,37+ {cmd: "share.who"}, {cmd: "share.hand"}, sep,3738 {title: "Settings", items: []menuItem{3839 {cmd: "settings.decimal"}, {cmd: "settings.locale"}, {cmd: "settings.vim"}, sep,3940 {cmd: "settings.theme"}, {cmd: "settings.jev_key"}, sep,@@ -128,6 +128,7 @@ type Model struct {
128128 term terminal // what the terminal supports: graphics.go129129 prefs prefs // the settings in effect and the theme chosen: prefs.go130130 session session // what outlasts the file open: the : history, the keyboard: keyboard.go131+ share shareState // the room the workbook is shared in, in 012 serve: share.go131132 132133 vim vimState // a vim key sequence in progress: vim.go133134 rec *recorder // a macro being recorded: macrorec.go@@ -147,7 +148,7 @@ type Model struct {
147148 148149 // New returns a model editing s. filename may be empty.149150 func New(s *sheet.Sheet, filename string) *Model {150- m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1},151+ m := &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(os.Getenv), charts: chartState{last: -1}, nb: nbState{runs: &nbRuns{}},151152 spans: telemetry.NewTrace(telemetry.Parent{})}152153 s.Book().SetTrace(m.spans)153154 if filename != "" {@@ -237,6 +238,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
237238 cmd = m.handleFollow(msg)238239 case sourceJobMsg, sourcePageMsg, sourceTickMsg:239240 cmd = m.handleSource(msg)241+ case roomMsg:242+ cmd = m.roomChanged()243+ case shareTickMsg:244+ m.share.ticking = false240245 case macroCallMsg:241246 cmd = m.serveMacro(msg)242247 case macroDoneMsg:@@ -269,7 +274,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
269274 // Any edit may have queued JEV questions.270275 // Chart images follow any change, see graphics.go.271276 // Linked regions may have come, gone or changed: follow.go.272- return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.syncSources(), m.notebookSync())277+ return m, tea.Batch(cmd, m.jev.send(m.spans.Parent()), m.term.syncImages(m.sheet, m.displayCharts, &m.th, m.spans), m.syncSixel(msg), m.syncFollowers(), m.syncSources(), m.notebookSync(), m.shareTick())273278 }274279 275280 // beginUpdate prepares for an input event and returns the sheet's state@@ -26,13 +26,19 @@ var named = map[string]tea.Key{
2626 // character at a time. Commands run and their messages are fed back,2727 // except tea.Quit, which is returned.2828 func press(t *testing.T, m *Model, keys ...string) tea.Msg {29+ t.Helper()30+ return pressTo(t, func(msg tea.Msg) tea.Msg { return send(m, msg) }, keys...)31+}32+33+// pressTo sends keys, as press does, through send.34+func pressTo(t *testing.T, send func(tea.Msg) tea.Msg, keys ...string) tea.Msg {2935 t.Helper()3036 var last tea.Msg3137 for _, k := range keys {3238 name, ok := strings.CutPrefix(k, "<")3339 if !ok {3440 for _, r := range k {35- if msg := send(m, tea.KeyPressMsg{Code: r, Text: string(r)}); msg != nil {41+ if msg := send(tea.KeyPressMsg{Code: r, Text: string(r)}); msg != nil {3642 last = msg3743 }3844 }@@ -59,7 +65,7 @@ func press(t *testing.T, m *Model, keys ...string) tea.Msg {
5965 t.Fatalf("unknown key %q", k)6066 }6167 key.Mod = mod62- if msg := send(m, tea.KeyPressMsg(key)); msg != nil {68+ if msg := send(tea.KeyPressMsg(key)); msg != nil {6369 last = msg6470 }6571 }@@ -75,6 +81,11 @@ func send(m *Model, msg tea.Msg) tea.Msg {
7581 }7682 7783 func run(m *Model, cmd tea.Cmd) tea.Msg {84+ return runVia(func(msg tea.Msg) tea.Cmd { _, next := m.Update(msg); return next }, cmd)85+}86+87+// runVia runs cmd as run does, handing its messages to update.88+func runVia(update func(tea.Msg) tea.Cmd, cmd tea.Cmd) tea.Msg {7889 if cmd == nil {7990 return nil8091 }@@ -91,20 +102,23 @@ func run(m *Model, cmd tea.Cmd) tea.Msg {
91102 if _, ok := out.(autoscrollMsg); ok {92103 return nil93104 }105+ if msg, ok := out.(nbStreamMsg); ok {106+ streamPolls = append(streamPolls, msg) // tests take them with pumpStreams107+ return nil108+ }94109 // Batches and sequences are slices of commands.95110 cmdType := reflect.TypeFor[tea.Cmd]()96111 if v := reflect.ValueOf(out); v.Kind() == reflect.Slice && v.Type().Elem() == cmdType {97112 for i := range v.Len() {98113 if c, ok := v.Index(i).Interface().(tea.Cmd); ok {99- if q := run(m, c); q != nil {114+ if q := runVia(update, c); q != nil {100115 return q101116 }102117 }103118 }104119 return nil105120 }106- _, next := m.Update(out)107- return run(m, next)121+ return runVia(update, update(out))108122 }109123 110124 func newModel() *Model {@@ -25,13 +25,12 @@ import (
2525 2626 // nbState is the notebooks' part of the model.2727 type nbState struct {28- views map[*sheet.Sheet]*nbview.View29- runner nushell.Runner // nu, or a fake in tests30- queue []nbQueued31- running *nbRun32- gen int33- count int // runs this session, for [n]34- clip []notebook.Cell28+ views map[*sheet.Sheet]*nbview.View29+ runner nushell.Runner // nu, or a fake in tests30+ // runs are the cells running and waiting: the model's own, or in a31+ // shared room the room's (share.go).32+ runs *nbRuns33+ clip []notebook.Cell3534 // words are nu's command names, asked for once (asked).3635 words []string3736 asked bool@@ -64,12 +63,28 @@ type nbState struct {
6463 // follow scrolls the view to each cell as it starts, while a run of6564 // several cells goes on and the user hasn't scrolled away.6665 follow bool66+ // streamTick is set while streaming cells' heads are ticking:67+ // nbstream.go.68+ streamTick bool6769 // out is the outputs' grids (nbgrid.go), and frame counts the frames6870 // drawn, which the grids clear what they keep for a frame by.6971 out outGrids7072 frame int7173 }7274 75+// nbRuns are the cells running, waiting and streaming. In a room of76+// 012 serve they are the room's, which every participant sees and any77+// may stop, and whose results reach whoever keeps the room then78+// (room.Seat.Post), so they outlast the participant who started them.79+type nbRuns struct {80+ queue []nbQueued81+ running *nbRun82+ gen int83+ count int // runs of this workbook, for [n]84+ // streams are the cells running as streams, by cell ID: nbstream.go.85+ streams map[int]*nbStream86+}87+7388 // nbQueued is a cell waiting to run.7489 type nbQueued struct {7590 s *sheet.Sheet@@ -119,10 +134,13 @@ func (m *Model) runner() nushell.Runner {
119134 // cellState is how cell id of notebook s stands.120135 func (m *Model) cellState(s *sheet.Sheet, id int) nbview.State {121136 var st nbview.State122- if r := m.nb.running; r != nil && r.s == s && r.id == id {137+ if r := m.nb.runs.running; r != nil && r.s == s && r.id == id {123138 st.Running, st.Started = true, r.start124139 }125- st.Waiting = slices.Contains(m.nb.queue, nbQueued{s, id})140+ if r := m.nb.runs.streams[id]; r != nil && r.s == s {141+ st.Running, st.Started, st.Live, st.Rows = true, r.start, true, r.rows142+ }143+ st.Waiting = slices.Contains(m.nb.runs.queue, nbQueued{s, id})126144 st.Stale = m.staleCells(s)[id]127145 cells := s.NotebookCells()128146 if i := slices.IndexFunc(cells, func(c notebook.Cell) bool { return c.ID == id }); i >= 0 {@@ -216,11 +234,11 @@ func (m *Model) queueCells(s *sheet.Sheet, cells []notebook.Cell, want []int) te
216234 return m.trustNotebook(func(m *Model) tea.Cmd {217235 for _, i := range order {218236 q := nbQueued{s, cells[i].ID}219- if !slices.Contains(m.nb.queue, q) && (m.nb.running == nil || m.nb.running.nbQueued != q) {220- m.nb.queue = append(m.nb.queue, q)237+ if !slices.Contains(m.nb.runs.queue, q) && (m.nb.runs.running == nil || m.nb.runs.running.nbQueued != q) {238+ m.nb.runs.queue = append(m.nb.runs.queue, q)221239 }222240 }223- if m.nb.running != nil {241+ if m.nb.runs.running != nil {224242 return nil225243 }226244 return m.nextCell()@@ -261,9 +279,9 @@ func (m *Model) trustNotebook(run func(*Model) tea.Cmd) tea.Cmd {
261279 262280 // nextCell starts the first cell queued.263281 func (m *Model) nextCell() tea.Cmd {264- for len(m.nb.queue) > 0 {265- q := m.nb.queue[0]266- m.nb.queue = m.nb.queue[1:]282+ for len(m.nb.runs.queue) > 0 {283+ q := m.nb.runs.queue[0]284+ m.nb.runs.queue = m.nb.runs.queue[1:]267285 c, ok := cellOf(q)268286 if !ok || c.Kind != notebook.Code {269287 continue@@ -274,6 +292,7 @@ func (m *Model) nextCell() tea.Cmd {
274292 m.failCell(q, c, why)275293 continue276294 }295+ m.stopStream(q.id) // run once, it no longer follows277296 run := &nbRun{nbQueued: q, source: c.Source, start: time.Now()}278297 job, err := m.jobFor(q.s, c, run)279298 if err != nil {@@ -299,17 +318,17 @@ func cellOf(q nbQueued) (notebook.Cell, bool) {
299318 // failCell gives a cell that can't run an output saying why, and stops300319 // the queue.301320 func (m *Model) failCell(q nbQueued, c notebook.Cell, why string) {302- m.nb.count++303- m.book().SetOutput(q.id, ¬ebook.Output{Err: why, Count: m.nb.count, Source: c.Source})304- m.nb.queue = nil321+ m.nb.runs.count++322+ m.book().SetOutput(q.id, ¬ebook.Output{Err: why, Count: m.nb.runs.count, Source: c.Source})323+ m.nb.runs.queue = nil305324 }306325 307326 // startCell runs a cell in the background.308327 func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {309- m.nb.gen++328+ m.nb.runs.gen++310329 ctx, cancel := context.WithCancel(context.Background())311- run.gen, run.cancel = m.nb.gen, cancel312- m.nb.running = run330+ run.gen, run.cancel = m.nb.runs.gen, cancel331+ m.nb.runs.running = run313332 if m.nb.follow {314333 m.viewOf(run.s).Reveal(run.id) // Run all: the view follows the cell running315334 }@@ -325,7 +344,7 @@ func (m *Model) startCell(run *nbRun, job nushell.Job) tea.Cmd {
325344 }326345 return nbDoneMsg{run: run, nuon: out, err: err}327346 }328- return tea.Batch(exec, m.tickCell(run.gen))347+ return tea.Batch(m.roomOwned(exec), m.tickCell(run.gen))329348 }330349 331350 // maxOutput is the most a run may print: past it the run is stopped.@@ -341,7 +360,7 @@ func (m *Model) tickCell(gen int) tea.Cmd {
341360 342361 // ticked keeps the running cell's head turning.343362 func (m *Model) ticked(msg nbTickMsg) tea.Cmd {344- if r := m.nb.running; r != nil && r.gen == msg.gen {363+ if r := m.nb.runs.running; r != nil && r.gen == msg.gen {345364 return m.tickCell(msg.gen)346365 }347366 return nil@@ -351,23 +370,23 @@ func (m *Model) ticked(msg nbTickMsg) tea.Cmd {
351370 // starts the next cell queued.352371 func (m *Model) finishCell(msg nbDoneMsg) tea.Cmd {353372 r := msg.run354- if m.nb.running != r {373+ if m.nb.runs.running != r {355374 return nil // stopped, and another started since356375 }357- m.nb.running = nil376+ m.nb.runs.running = nil358377 c, ok := cellOf(r.nbQueued)359378 if !ok {360379 return m.nextCell()361380 }362- m.nb.count++363- o := ¬ebook.Output{Count: m.nb.count, Took: time.Since(r.start), Source: r.source, Reads: r.reads, Selection: r.selection}381+ m.nb.runs.count++382+ o := ¬ebook.Output{Count: m.nb.runs.count, Took: time.Since(r.start), Source: r.source, Reads: r.reads, Selection: r.selection}364383 if msg.err != nil {365384 o.Err = runError(msg.err)366385 var nuErr *nushell.Error367386 if errors.As(msg.err, &nuErr) {368387 o.Detail = nuErr.Help()369388 }370- m.nb.queue = nil389+ m.nb.runs.queue = nil371390 } else {372391 o.NUON = msg.nuon373392 if o.NUON == nil {@@ -380,13 +399,13 @@ func (m *Model) finishCell(msg nbDoneMsg) tea.Cmd {
380399 cells := r.s.NotebookCells()381400 if i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID }); i >= 0 {382401 for _, j := range notebook.Dependents(cells, i) {383- if q := (nbQueued{r.s, cells[j].ID}); !slices.Contains(m.nb.queue, q) {384- m.nb.queue = append(m.nb.queue, q)402+ if q := (nbQueued{r.s, cells[j].ID}); !slices.Contains(m.nb.runs.queue, q) {403+ m.nb.runs.queue = append(m.nb.runs.queue, q)385404 }386405 }387406 }388407 }389- if len(m.nb.queue) == 0 && time.Since(r.start) > 5*time.Second {408+ if len(m.nb.runs.queue) == 0 && time.Since(r.start) > 5*time.Second {390409 return tea.Batch(m.nextCell(), m.term.notify("Notebook cells finished in "+m.displayName()))391410 }392411 return m.nextCell()@@ -404,13 +423,14 @@ func runError(err error) string {
404423 return err.Error()405424 }406425 407-// stopCells stops the cell running, killing its process, and forgets408-// those waiting.426+// stopCells stops the cell running, killing its process, forgets427+// those waiting, and stops every stream.409428 func (m *Model) stopCells() {410- if r := m.nb.running; r != nil {429+ if r := m.nb.runs.running; r != nil {411430 r.cancel()412431 }413- m.nb.queue = nil432+ m.nb.runs.queue = nil433+ m.stopStreams()414434 }415435 416436 // clearOutputs clears every output of the notebook shown; restart also@@ -418,8 +438,8 @@ func (m *Model) stopCells() {
418438 func (m *Model) clearOutputs(restart bool) {419439 if restart {420440 m.stopCells()421- m.nb.running = nil422- m.nb.count = 0441+ m.nb.runs.running = nil442+ m.nb.runs.count = 0423443 }424444 for _, c := range m.sheet.NotebookCells() {425445 if m.book().Output(c.ID) != nil {@@ -115,14 +115,19 @@ func (m *Model) notebookToolbar(v *nbview.View) string {
115115 // nbKernel is how running stands in notebook s, for its toolbar.116116 func (m *Model) nbKernel(s *sheet.Sheet) nbview.Kernel {117117 k := nbview.Kernel{Off: m.shellOff(), Reactive: s.Reactive(), Clip: len(m.nb.clip)}118- if r := m.nb.running; r != nil && r.s == s {118+ if r := m.nb.runs.running; r != nil && r.s == s {119119 k.Busy = true120120 }121- for _, q := range m.nb.queue {121+ for _, q := range m.nb.runs.queue {122122 if q.s == s {123123 k.Waiting++124124 }125125 }126+ for _, st := range m.nb.runs.streams {127+ if st.s == s {128+ k.Live++129+ }130+ }126131 return k127132 }128133 @@ -259,6 +264,10 @@ func (m *Model) notebookMsg(msg tea.Msg) (tea.Cmd, bool) {
259264 return nil, true260265 case nbTickMsg:261266 return m.ticked(msg), true267+ case nbStreamMsg:268+ return m.streamPolled(msg), true269+ case nbStreamTickMsg:270+ return m.streamTicked(), true262271 }263272 for _, v := range m.nb.views {264273 if cmd, ok := v.Update(msg); ok {@@ -0,0 +1,212 @@
1+package ui2+3+import (4+ "context"5+ "io"6+ "slices"7+ "strconv"8+ "time"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/live"13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/nushell"15+)16+17+// Cells run as streams (Run as stream): the pipeline runs until it ends18+// or is stopped, and each value it yields reaches the cell's output, and19+// the sheet the output was sent to, as soon as nu prints it, so20+// `tail -f app.log | lines | parse ...` or `watch` is followed live. The21+// run is a live.Stream, the linked regions' source: it is polled one22+// poll at a time, each poll waiting for what the pipeline prints, and23+// what it gives is applied as a live operation to the output's region24+// (sheet.LiveOp, outside the undo history) and kept as the cell's25+// output, its last live.StreamKeep values. Streams run beside the queue26+// of cells run once, each on its own; Stop ends them all.27+28+// nbStream is a cell running as a stream.29+type nbStream struct {30+ *nbRun31+ src *live.Stream32+ count int // the run's number, [n]33+ rows int // rows printed so far34+ // shown is when the output was last updated, so a fast stream35+ // updates it a few times a second rather than on every poll, and36+ // behind is set when rows came since.37+ shown time.Time38+ behind bool39+}40+41+// nbStreamMsg is what a stream's poll found.42+type nbStreamMsg struct {43+ st *nbStream44+ u live.Update45+ ok bool46+}47+48+// streamOutputEvery is how often a stream's output is rebuilt, at most:49+// rows reach a sheet the output was sent to on every poll.50+const streamOutputEvery = 100 * time.Millisecond51+52+// streamSelected runs the selected code cell as a stream.53+func (m *Model) streamSelected() tea.Cmd {54+ v := m.nbView()55+ v.StopEdit()56+ c, ok := v.Cell()57+ if !ok || c.Kind != notebook.Code {58+ return nil59+ }60+ s := m.sheet61+ return m.trustNotebook(func(m *Model) tea.Cmd { return m.startStream(nbQueued{s, c.ID}) })62+}63+64+// startStream starts cell q as a stream, in place of any stream or65+// queued run of it.66+func (m *Model) startStream(q nbQueued) tea.Cmd {67+ c, ok := cellOf(q)68+ if !ok {69+ return nil70+ }71+ m.stopStream(q.id)72+ m.nb.runs.queue = slices.DeleteFunc(m.nb.runs.queue, func(x nbQueued) bool { return x == q })73+ cells := q.s.NotebookCells()74+ i := slices.IndexFunc(cells, func(x notebook.Cell) bool { return x.ID == c.ID })75+ if why := notebook.Taken(cells, i); why != "" {76+ m.failCell(q, c, why)77+ return nil78+ }79+ run := &nbRun{nbQueued: q, source: c.Source, start: time.Now()}80+ job, err := m.jobFor(q.s, c, run)81+ if err != nil {82+ m.failCell(q, c, err.Error())83+ return nil84+ }85+ runner, parent := m.runner(), m.spans.Parent()86+ src := live.NewStream(func(ctx context.Context, w io.Writer) error {87+ span := parent.Start("nu.stream")88+ err := nushell.Stream(ctx, runner, job, w)89+ span.End()90+ return err91+ })92+ src.Wait = streamOutputEvery // an idle poll brings the output up to date93+ m.nb.runs.count++94+ st := &nbStream{nbRun: run, src: src, count: m.nb.runs.count}95+ if m.nb.runs.streams == nil {96+ m.nb.runs.streams = map[int]*nbStream{}97+ }98+ m.nb.runs.streams[q.id] = st99+ m.showStream(st)100+ st.shown = time.Time{} // the first rows show at once101+ m.feedOutput(c.Name())102+ m.note = "Following cell " + strconv.Itoa(i+1) + " as a stream: Stop (i i) ends it"103+ return tea.Batch(m.roomOwned(pollStream(st)), m.tickStreams())104+}105+106+// pollStream polls st's stream on a goroutine of its own.107+func pollStream(st *nbStream) tea.Cmd {108+ return func() tea.Msg {109+ u, ok := st.src.Poll(context.Background())110+ return nbStreamMsg{st, u, ok}111+ }112+}113+114+// streamPolled applies what a stream's poll found: its rows to the region115+// the output was sent to, and to the output, until the stream ends.116+func (m *Model) streamPolled(msg nbStreamMsg) tea.Cmd {117+ st := msg.st118+ if m.nb.runs.streams[st.id] != st {119+ st.src.Close() // stopped, or started again since120+ return nil121+ }122+ c, ok := cellOf(st.nbQueued)123+ if !ok {124+ m.stopStream(st.id)125+ return nil126+ }127+ st.rows = st.src.Rows()128+ st.behind = st.behind || msg.ok129+ if msg.ok {130+ if _, r, ok := m.book().Region(c.Name()); ok && r.Output {131+ if err := m.book().ApplyLive(msg.u.Op(r.Name)); err != nil {132+ m.warn = err.Error()133+ }134+ }135+ }136+ if done, err := st.src.Ended(); done {137+ delete(m.nb.runs.streams, st.id)138+ m.endStream(st, err)139+ return nil140+ }141+ if st.behind && time.Since(st.shown) >= streamOutputEvery {142+ m.showStream(st)143+ }144+ return m.roomOwned(pollStream(st))145+}146+147+// showStream keeps what the stream has printed so far as the cell's148+// output.149+func (m *Model) showStream(st *nbStream) {150+ st.shown, st.behind = time.Now(), false151+ m.book().SetOutput(st.id, ¬ebook.Output{NUON: st.src.NUON(), Count: st.count, Took: time.Since(st.start),152+ Source: st.source, Reads: st.reads, Selection: st.selection})153+}154+155+// endStream keeps a stream's output as it ended: its rows, and why it156+// failed if it did.157+func (m *Model) endStream(st *nbStream, err error) {158+ m.showStream(st)159+ if err != nil {160+ o := *m.book().Output(st.id)161+ o.Err = runError(err)162+ m.book().SetOutput(st.id, &o)163+ }164+}165+166+// stopStream stops cell id's stream, keeping what it printed.167+func (m *Model) stopStream(id int) {168+ st := m.nb.runs.streams[id]169+ if st == nil {170+ return171+ }172+ delete(m.nb.runs.streams, id)173+ st.src.Close()174+ m.showStream(st)175+}176+177+// stopStreams stops every stream.178+func (m *Model) stopStreams() {179+ for id := range m.nb.runs.streams {180+ m.stopStream(id)181+ }182+}183+184+// closeStreams stops every stream's process as the program ends,185+// touching nothing else: the model may be broken by a crash.186+func (m *Model) closeStreams() {187+ for _, st := range m.nb.runs.streams {188+ st.src.Close()189+ }190+}191+192+// tickStreams redraws streaming cells' heads while any runs, so the193+// toolbar and the output's run time keep up.194+func (m *Model) tickStreams() tea.Cmd {195+ if m.nb.still || m.nb.streamTick {196+ return nil197+ }198+ m.nb.streamTick = true199+ return tea.Tick(time.Second, func(time.Time) tea.Msg { return nbStreamTickMsg{} })200+}201+202+// nbStreamTickMsg redraws streaming cells.203+type nbStreamTickMsg struct{}204+205+// streamTicked keeps the ticks going while a stream runs.206+func (m *Model) streamTicked() tea.Cmd {207+ m.nb.streamTick = false208+ if len(m.nb.runs.streams) == 0 {209+ return nil210+ }211+ return m.tickStreams()212+}@@ -0,0 +1,139 @@
1+package ui2+3+import (4+ "context"5+ "io"6+ "strings"7+ "testing"8+9+ "github.com/FelineStateMachine/012/internal/nushell"10+)11+12+// streamPolls are the stream polls commands returned in tests, which13+// run doesn't follow on its own (see pumpStreams).14+var streamPolls []nbStreamMsg15+16+// pumpStreams hands the model the stream polls waiting, and those they17+// start, until a poll finds nothing new after one found rows.18+func pumpStreams(m *Model) {19+ rows := false20+ for i := 0; i < 50 && len(streamPolls) > 0; i++ {21+ msg := streamPolls[0]22+ streamPolls = streamPolls[1:]23+ send(m, msg)24+ if msg.ok {25+ rows = true26+ } else if rows {27+ return28+ }29+ }30+}31+32+// streamNu prints each line sent on lines, as a stream's pipeline33+// would, until lines is closed or the run is stopped.34+type streamNu struct {35+ lines chan string36+ scripts chan string37+}38+39+func newStreamNu() *streamNu {40+ return &streamNu{lines: make(chan string), scripts: make(chan string, 8)}41+}42+43+func (f *streamNu) Run(ctx context.Context, job nushell.Job, script string, stdout io.Writer) error {44+ if len(job.IDE) > 0 || job.Command == "help commands | get name" {45+ return nushell.ErrMissing46+ }47+ f.scripts <- script48+ for {49+ select {50+ case <-ctx.Done():51+ return ctx.Err()52+ case l, ok := <-f.lines:53+ if !ok {54+ return nil55+ }56+ io.WriteString(stdout, l+"\n")57+ }58+ }59+}60+61+// A cell run as a stream shows as live, its rows reaching its output62+// and the sheet it was sent to as the pipeline prints them, and Stop63+// ends it keeping them.64+func TestNotebookStream(t *testing.T) {65+ streamPolls = nil66+ m, _ := notebookModel(t, nil)67+ nu := newStreamNu()68+ m.SetShellRunner(nu)69+ write(t, m, "log = tail -f app.log | lines | parse '{level} {msg}'")70+ press(t, m, "f")71+ if len(m.nb.runs.streams) != 1 || !strings.Contains(screen(m), "● live, 0 rows") {72+ t.Fatalf("not live:\n%s", screen(m))73+ }74+ if script := <-nu.scripts; !strings.Contains(script, "to nuon --raw | print") {75+ t.Errorf("not run as a stream: %s", script)76+ }77+ // Send it to a new sheet while it runs.78+ press(t, m, "G", "<enter>")79+ logs := m.book().Lookup("log")80+ if logs == nil {81+ t.Fatalf("no sheet log: %q", m.note)82+ }83+ nb := m.sheet84+ m.showSheet(m.book().Sheets()[0])85+86+ nu.lines <- "{level: info, msg: started}"87+ pumpStreams(m)88+ nu.lines <- "{level: warn, msg: slow}"89+ pumpStreams(m)90+ if got := logs.Value(addr("B3")).String(); got != "slow" {91+ t.Errorf("B3 = %q", got)92+ }93+ if got := logs.Value(addr("A1")).String(); got != "level" {94+ t.Errorf("A1 = %q", got)95+ }96+ st := m.cellState(nb, firstStream(m))97+ if !st.Live || !st.Running || st.Rows != 2 {98+ t.Errorf("state %+v", st)99+ }100+ run(m, m.runCommand("nb.stop"))101+ if len(m.nb.runs.streams) != 0 {102+ t.Fatal("still streaming after Stop")103+ }104+ m.showSheet(nb)105+ c, _ := m.nbCell()106+ o := m.book().Output(c.ID)107+ if o == nil || o.Failed() || !strings.Contains(string(o.NUON), "slow") {108+ t.Errorf("output after Stop: %+v", o)109+ }110+ if got := logs.Value(addr("B3")).String(); got != "slow" {111+ t.Errorf("after Stop B3 = %q", got)112+ }113+ if strings.Contains(screen(m), "live") {114+ t.Errorf("still shown live:\n%s", screen(m))115+ }116+}117+118+func firstStream(m *Model) int {119+ for id := range m.nb.runs.streams {120+ return id121+ }122+ return -1123+}124+125+// A cell running as a stream reads without color: ● live and its rows126+// in words, and ■ for ▶.127+func TestMonochromeStream(t *testing.T) {128+ streamPolls = nil129+ m, _ := notebookModel(t, nil)130+ nu := newStreamNu()131+ m.SetShellRunner(nu)132+ write(t, m, "tail -f app.log | lines")133+ press(t, m, "f")134+ defer m.stopCells()135+ nu.lines <- `"started"`136+ pumpStreams(m)137+ findLine(t, m, "● live, 1 row")138+ findLine(t, m, "■")139+}@@ -176,6 +176,8 @@ func (v *View) state(c notebook.Cell) (string, lipgloss.Style) {
176176 switch {177177 case st.Problem != "":178178 return st.Problem, th.Warning179+ case st.Live:180+ return live(st.Rows), th.CellHead179181 case st.Running:180182 return running(time.Since(st.Started)), th.CellHead181183 case st.Waiting:@@ -236,6 +238,16 @@ func running(d time.Duration) string {
236238 return spinner[int(d/(100*time.Millisecond))%len(spinner)] + " running " + duration(d)237239 }238240 241+// live is a streaming cell's state: ● live, and how many rows it has242+// printed.243+func live(rows int) string {244+ s := "● live, " + itoa(rows) + " row"245+ if rows != 1 {246+ s += "s"247+ }248+ return s249+}250+239251 func took(o *notebook.Output) string {240252 if o.Took <= 0 {241253 return ""@@ -142,6 +142,10 @@ func (v *View) status(k Kernel) string {
142142 switch {143143 case k.Off != "":144144 s = th.Warning.Render("nu ⊘ off")145+ case k.Live > 0 && (k.Busy || k.Waiting > 0):146+ s = th.Hint.Render("nu ● busy, " + itoa(k.Live) + " live")147+ case k.Live > 0:148+ s = th.Hint.Render("nu ● " + itoa(k.Live) + " live")145149 case k.Busy && k.Waiting > 0:146150 s = th.Hint.Render("nu ● busy, " + itoa(k.Waiting) + " waiting")147151 case k.Busy || k.Waiting > 0:@@ -45,7 +45,11 @@ type Host interface {
4545 type State struct {4646 Waiting, Running bool4747 Started time.Time // when it started running48- Stale bool // what it read, or its source, changed since it ran48+ // Live is set while it runs as a stream, and Rows counts the rows49+ // the stream has printed.50+ Live bool51+ Rows int52+ Stale bool // what it read, or its source, changed since it ran4953 // Problem says why it can't run as it is: its name is taken.5054 Problem string5155 }@@ -55,6 +59,7 @@ type State struct {
5559 type Kernel struct {5660 Busy bool // a cell is running5761 Waiting int // cells waiting to run62+ Live int // cells running as streams5863 Off string // why cells don't run here, or ""5964 Reactive bool // cells reading a cell run again when it runs6065 Clip int // cells copied, for Paste@@ -43,6 +43,7 @@ var nbKeys = map[string]string{
4343 "n": "nb.name",4444 "G": "nb.send",4545 "i i": "nb.stop",46+ "f": "nb.stream", // follow4647 "0 0": "nb.restart",4748 "f9": "nb.run_all",4849 "V": "nb.paste_above",@@ -110,7 +111,7 @@ func nbHelpRows(listed map[string]bool) []helpRow {
110111 // notebookItems are Data > Shell's items.111112 var notebookItems = []menuItem{112113 {cmd: "nb.open"}, sep,113- {cmd: "nb.run"}, {cmd: "nb.run_next"}, {cmd: "nb.run_all"}, {cmd: "nb.run_above"}, {cmd: "nb.run_below"}, {cmd: "nb.stop"}, sep,114+ {cmd: "nb.run"}, {cmd: "nb.run_next"}, {cmd: "nb.run_all"}, {cmd: "nb.run_above"}, {cmd: "nb.run_below"}, {cmd: "nb.stream"}, {cmd: "nb.stop"}, sep,114115 {cmd: "nb.insert_above"}, {cmd: "nb.insert_below"}, {cmd: "nb.delete"}, {cmd: "nb.move_up"}, {cmd: "nb.move_down"}, sep,115116 {cmd: "nb.to_code"}, {cmd: "nb.to_note"}, {cmd: "nb.name"}, sep,116117 {cmd: "nb.copy"}, {cmd: "nb.cut"}, {cmd: "nb.paste"}, {cmd: "nb.paste_above"}, sep,@@ -122,7 +123,7 @@ var notebookItems = []menuItem{
122123 123124 // nbCellMenu is a cell's context menu, on a right click.124125 var nbCellMenu = []menuItem{125- {cmd: "nb.run"}, {cmd: "nb.run_below"}, sep,126+ {cmd: "nb.run"}, {cmd: "nb.run_below"}, {cmd: "nb.stream"}, sep,126127 {cmd: "nb.cut"}, {cmd: "nb.copy"}, {cmd: "nb.paste"}, {cmd: "nb.paste_above"}, {cmd: "nb.delete"}, sep,127128 {cmd: "nb.move_up"}, {cmd: "nb.move_down"}, {cmd: "nb.to_code"}, {cmd: "nb.to_note"}, {cmd: "nb.name"}, sep,128129 {cmd: "nb.toggle_output"}, {cmd: "nb.toggle_whole"}, {cmd: "nb.clear_output"}, {cmd: "nb.open_output"}, {cmd: "nb.send"},@@ -152,9 +153,13 @@ func init() {
152153 enabled: onCell, run: func(m *Model) tea.Cmd { i, _ := m.nbView().Selected(); return m.runRange(0, i) }},153154 &command{id: "nb.run_below", macro: macroNever, title: "Run cell and below", desc: "Run the selected cell and every code cell under it",154155 enabled: onCell, run: func(m *Model) tea.Cmd { i, _ := m.nbView().Selected(); return m.runRange(i, -1) }},155- &command{id: "nb.stop", macro: macroNever, title: "Stop running", desc: "Stop the cell running, killing its process, and those waiting",156- enabled: func(m *Model) bool { return m.nb.running != nil || len(m.nb.queue) > 0 },157- run: func(m *Model) tea.Cmd { m.stopCells(); return nil }},156+ &command{id: "nb.stream", macro: macroNever, title: "Run as stream", desc: "Run the selected cell until it's stopped, its rows arriving as the pipeline prints them (tail -f, watch)",157+ enabled: onCode, run: (*Model).streamSelected},158+ &command{id: "nb.stop", macro: macroNever, title: "Stop running", desc: "Stop the cell running, killing its process, those waiting and the cells running as streams",159+ enabled: func(m *Model) bool {160+ return m.nb.runs.running != nil || len(m.nb.runs.queue) > 0 || len(m.nb.runs.streams) > 0161+ },162+ run: func(m *Model) tea.Cmd { m.stopCells(); return nil }},158163 &command{id: "nb.clear_outputs", title: "Clear outputs", desc: "Clear every cell's output",159164 enabled: onTab, run: func(m *Model) tea.Cmd { m.clearOutputs(false); return nil }},160165 &command{id: "nb.restart", title: "Restart", desc: "Stop what's running, clear every output and count runs from 1 again",@@ -234,7 +234,7 @@ func TestNotebookRestartAndToggle(t *testing.T) {
234234 t.Errorf("o didn't show all of it:\n%s", s)235235 }236236 press(t, m, "0", "0")237- if m.book().Output(m.sheet.NotebookCells()[0].ID) != nil || m.nb.count != 0 {237+ if m.book().Output(m.sheet.NotebookCells()[0].ID) != nil || m.nb.runs.count != 0 {238238 t.Error("0 0 left outputs")239239 }240240 }@@ -34,6 +34,7 @@ func (m *Model) View() tea.View {
3434 m.nb.frame++ // and outputs' grids clear theirs: nbgrid.go3535 clear(m.mergeLines)3636 clear(m.shaded)37+ m.frameShare()3738 var lines []string3839 if v := m.nbView(); v != nil {3940 lines = m.notebookLines(v)@@ -141,6 +142,9 @@ func (m *Model) displayName() string {
141142 if m.filename == "" && m.xfer.Source != "" {142143 return filepath.Base(m.xfer.Source)143144 }145+ if m.filename == "" && m.share.seat != nil && strings.HasPrefix(m.share.seat.Key(), "@") {146+ return m.share.seat.Key() // a named room not saved yet147+ }144148 if m.filename == "" {145149 return "untitled"146150 }@@ -303,7 +307,7 @@ func (m *Model) readyContext() (left, right string) {
303307 return m.traceLine()304308 }305309 for _, f := range []func() string{func() string { return m.jev.line(&m.th, m.sheet.RemoteCalls(m.cur)) },306- m.errorLine, m.validationLine, m.noteLine, m.spillLine, m.regionLine, m.tableLine, m.recordingLine} {310+ m.errorLine, m.shareLine, m.validationLine, m.noteLine, m.spillLine, m.regionLine, m.tableLine, m.recordingLine} {307311 if left = f(); left != "" {308312 return left, ""309313 }@@ -439,6 +443,7 @@ func (m *Model) statusState() string {
439443 if m.changed {440444 b.WriteString(m.th.Muted.Render(" modified"))441445 }446+ b.WriteString(m.peersStatus())442447 if m.book().Decimal() {443448 b.WriteString(m.th.Muted.Render(" decimal"))444449 }@@ -173,7 +173,7 @@ func TestPipeCommands(t *testing.T) {
173173 return out174174 }175175 m := newModel()176- if slices.Contains(titles(m), "Quit and send sheet") || len(m.applicable(menuBar[0].items)) != len(menuBar[0].items)-3 {176+ if slices.Contains(titles(m), "Quit and send sheet") || len(m.applicable(menuBar[0].items)) != len(menuBar[0].items)-5 { // the send commands, and sharing outside 012 serve177177 t.Error("send commands outside a pipeline")178178 }179179 m = stdinModel(t, filesNUON, true, 0)@@ -35,14 +35,14 @@ func init() {
3535 &command{id: "data.pivot_edit", title: "Edit pivot table", desc: "Change the pivot table's rows, columns, values and filters",3636 enabled: func(m *Model) bool { _, ok := m.sheet.Pivot(); return ok },3737 run: func(m *Model) tea.Cmd {38- m.openPivotEditor(m.sheet.StateID(), nil)38+ m.openPivotEditor(m.book().Checkpoint(), nil)3939 return nil4040 },4141 answer: func(m *Model, text string) (tea.Cmd, error) {4242 if _, ok := m.sheet.Pivot(); !ok {4343 return nil, errors.New(m.sheet.Name() + " has no pivot table")4444 }45- return nil, m.openPivotEditor(m.sheet.StateID(), nil).answer(m, text)45+ return nil, m.openPivotEditor(m.book().Checkpoint(), nil).answer(m, text)4646 }},4747 &command{id: "data.frequency", title: "Frequency table (column stats)", desc: "Count each value of the active column, most frequent first, on a new sheet, as Sheets' Column stats do",4848 run: (*Model).frequency},@@ -57,6 +57,9 @@ func init() {
5757 // keepsSpills says the edit moves or formats cells rather than writing5858 // them), which can't be edited, saying so on the context line.5959 func (m *Model) refuseEdit(r sheet.Rect, keepsSpills bool) bool {60+ if !m.mayEdit() {61+ return true62+ }6063 if m.out != nil {6164 m.note = "An output can't be changed: G sends it to a sheet, where its copy can"6265 return true@@ -164,7 +167,7 @@ func (m *Model) createPivot() tea.Cmd {
164167 m.note = "Select the data to summarize first: a row of headers and the rows below it"165168 return nil166169 }167- src, start := m.sheet, m.sheet.StateID()170+ src, start := m.sheet, m.book().Checkpoint()168171 s, err := m.book().CreatePivot(src, r, "", sheet.NewPivot(src, r))169172 if err != nil {170173 m.fail(err.Error())@@ -24,7 +24,7 @@ import (
2424 // created.2525 type pivotEditor struct {2626 m pivotHost // the model, through what the editor needs of it27- start int // the workbook's state before editing, to undo back to27+ start int // the history before editing (Checkpoint), to undo back to2828 back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc2929 before string // the pivot when the editor opened, as a macro answers it3030 msg string // why the last change was refused, for the status line@@ -223,9 +223,7 @@ func (e *pivotEditor) Key(k tea.KeyPressMsg) tea.Cmd {
223223 // cancel undoes the editor's changes and closes it; a pivot just created224224 // goes with its sheet.225225 func (e *pivotEditor) cancel(m pivotHost) {226- for m.sheetShown().StateID() != e.start && m.sheetShown().CanUndo() {227- m.sheetShown().Undo()228- }226+ m.book().UndoTo(e.start)229227 m.closeOverlay()230228 m.afterSheetsChange(e.back, m.book().Active())231229 m.syncChanged()@@ -81,6 +81,10 @@ func (m *Model) startOpenCmd() tea.Cmd {
8181 m.offerRecovery("")8282 return nil8383 }84+ if m.share.reg != nil && strings.HasPrefix(name, "@") {85+ m.joinNamed(name)86+ return nil87+ }8488 if _, ok := fileio.KindOf(name); ok {8589 return m.startImport(name, fileio.Options{}, transfer.Book)8690 }@@ -89,6 +93,9 @@ func (m *Model) startOpenCmd() tea.Cmd {
8993 if !ok {9094 return nil9195 }96+ if m.share.reg != nil {97+ return m.openShared(name, p)98+ }9299 if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) {93100 // A new sheet, saved under this name.94101 m.filename, m.disk = name, stamp{}@@ -333,6 +340,10 @@ func (m *Model) restored(msg restoredMsg) {
333340 m.fail("Couldn't restore the kept changes: " + msg.err.Error())334341 return335342 }343+ if seat := m.share.seat; seat != nil {344+ m.restoreShared(seat, msg)345+ return346+ }336347 disk := m.disk337348 m.reset(msg.sheet, msg.name)338349 m.disk, m.saved, m.changed = disk, -1, true@@ -0,0 +1,190 @@
1+package ui2+3+import (4+ "context"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+// Shared is a served session's guarded model as Bubble Tea runs it when12+// workbooks are shared: every turn the model takes (Init, Update, View)13+// holds its room's lock (room.Seat.Do), with its steps made in its own14+// name, so sessions act on the workbook one at a time, in the order the15+// server takes them. Between turns it joins and leaves rooms, never16+// holding two locks. It also holds back input while someone else's step17+// is open (a macro they run spans several turns) and hands it on once18+// the step ends, and in a one-writer room it takes back what a19+// follower changed by a way no check stopped.20+type Shared struct {21+ g *Guarded22+}23+24+// InRooms runs g's model in the rooms it was given (Model.ShareRooms).25+func InRooms(g *Guarded) *Shared { return &Shared{g: g} }26+27+// Attach sends the program a roomMsg whenever the model's room changes,28+// until ctx ends.29+func (s *Shared) Attach(ctx context.Context, p *tea.Program) {30+ link := s.g.m.share.link31+ go func() {32+ for {33+ select {34+ case <-ctx.Done():35+ return36+ case <-link.kick:37+ p.Send(roomMsg{})38+ }39+ }40+ }()41+}42+43+// Init implements tea.Model.44+func (s *Shared) Init() tea.Cmd {45+ var cmd tea.Cmd46+ s.turn(func() { cmd = s.g.Init() })47+ return tea.Batch(cmd, s.between())48+}49+50+// Update implements tea.Model.51+func (s *Shared) Update(msg tea.Msg) (tea.Model, tea.Cmd) {52+ var cmd tea.Cmd53+ s.turn(func() { cmd = s.update(msg) })54+ return s, tea.Batch(cmd, s.between())55+}56+57+// View implements tea.Model.58+func (s *Shared) View() tea.View {59+ var v tea.View60+ s.turn(func() { v = s.g.View() })61+ return v62+}63+64+// update is a turn's Update: input held while another's step is open,65+// and handed on once it ends.66+func (s *Shared) update(msg tea.Msg) tea.Cmd {67+ m := s.g.m68+ seat := m.share.seat69+ if seat != nil && isInput(msg) {70+ if open, who := seat.Book().InStep(); open && who != seat.ID() {71+ m.share.held = append(m.share.held, msg)72+ return nil73+ }74+ }75+ _, cmd := s.g.Update(msg)76+ if _, ok := msg.(roomMsg); !ok || len(m.share.held) == 0 {77+ return cmd78+ }79+ if open, _ := seat.Book().InStep(); open {80+ return cmd81+ }82+ held := m.share.held83+ m.share.held = nil84+ cmds := []tea.Cmd{cmd}85+ for _, in := range held {86+ _, c := s.g.Update(in)87+ cmds = append(cmds, c)88+ }89+ return tea.Batch(cmds...)90+}91+92+// isInput reports whether msg is the user's input, which waits while93+// another's step is open.94+func isInput(msg tea.Msg) bool {95+ switch msg.(type) {96+ case tea.KeyPressMsg, tea.PasteMsg, tea.MouseClickMsg, tea.MouseReleaseMsg, tea.MouseMotionMsg, tea.MouseWheelMsg:97+ return true98+ }99+ return false100+}101+102+// turn runs fn holding the model's room, if it has one; after it, the103+// others learn where the user is.104+func (s *Shared) turn(fn func()) {105+ m := s.g.m106+ seat := m.share.seat107+ if seat == nil {108+ fn()109+ return110+ }111+ seat.Do(func(w *sheet.Workbook) {112+ w.SetTrace(m.spans)113+ before := w.LastOp()114+ fn()115+ if s.g.crash != nil {116+ if open, who := w.InStep(); open && who == seat.ID() {117+ w.EndStep() // the crashed session's macro ends here118+ }119+ return120+ }121+ if m.share.seat == seat && m.book() == w {122+ s.g.m.takeBack(w, before)123+ seat.Move(m.presence())124+ }125+ })126+}127+128+// takeBack undoes the steps a follower made in a one-writer room, which129+// only the writer edits.130+func (m *Model) takeBack(w *sheet.Workbook, before uint64) {131+ seat := m.share.seat132+ if seat.Writing() {133+ return134+ }135+ ops, _ := w.OpsSince(before)136+ for _, o := range ops {137+ if o.Author == seat.ID() && o.Kind == sheet.OpDo {138+ w.Undo()139+ m.mayEdit() // says why140+ }141+ }142+}143+144+// between joins and leaves rooms after a turn: leaving the room of a145+// workbook the model no longer shows, and joining the one it asked for.146+func (s *Shared) between() tea.Cmd {147+ m := s.g.m148+ if s.g.crash != nil {149+ return nil150+ }151+ if seat := m.share.seat; seat != nil && (m.share.want != nil || m.book() != seat.Book()) {152+ m.share.seat = nil153+ m.leftRoom(seat.Leave())154+ }155+ req := m.share.want156+ if req == nil {157+ return nil158+ }159+ m.share.want = nil160+ seat, created := m.share.reg.Join(req.key, m.share.link, func() *sheet.Workbook {161+ if req.book == nil {162+ return sheet.New().Book()163+ }164+ return req.book165+ })166+ var cmd tea.Cmd167+ seat.Do(func(*sheet.Workbook) {168+ cmd = s.g.do(func() tea.Cmd { return m.enterRoom(seat, created, req) })169+ seat.Move(m.presence())170+ })171+ return cmd172+}173+174+// leftRoom lets go of what the model shared in a room it left: the last175+// one out stops what the room ran; otherwise it goes on without this176+// model.177+func (m *Model) leftRoom(last bool) {178+ if last {179+ m.stopCells()180+ return181+ }182+ m.nb.runs = &nbRuns{}183+ for _, f := range m.follow.by {184+ m.dropFollower(f)185+ }186+ m.src.run = &sourceHost{} // the room's goes on with the others187+}188+189+// Model is the model run.190+func (s *Shared) Model() *Model { return s.g.m }@@ -0,0 +1,310 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/room"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Sharing a workbook between the sessions of 012 serve: every session15+// that opens the same file (or joins the same named room, @name) sits16+// in one room (package room) and works on its one workbook, each with17+// its own cursor, scroll, selection, theme and modes. The session's18+// model runs its turns (Update, View) under the room's lock (Shared,19+// rooms.go), so the workbook's one mutation path orders everyone's20+// steps; each step is its author's, so undo takes back only one's own21+// (sheet/authors.go). When the room changes, the others are told22+// (roomMsg), draw what changed and see where each other is23+// (sharedraw.go). Saving is the room's: every session counts unsaved24+// changes from the last save anyone made, and asks about them only25+// when it's the last one there. Linked files are followed, and26+// notebook cells run, by the room (nbRuns, follow.go), so they go on27+// while anyone is there. See docs/terminal/ssh.md.28+29+// shareState is a served session's part in the rooms.30+type shareState struct {31+ reg *room.Registry // nil: not shared (the local app, or serve without rooms)32+ link *peerLink // the session as a participant33+ seat *room.Seat // the room of the workbook shown; nil in a workbook of its own34+ want *joinReq // a room to join once the turn is over35+ seen uint64 // the room's last operation this model has looked at36+ held []tea.Msg // input held while another's step is open (a macro running)37+ // entryFrom is the room's last operation when the entry being typed38+ // began, to tell whether someone changed the cell meanwhile.39+ entryFrom uint6440+ // frame is what the frame draws of the others, and ticking is set41+ // while a redraw waits for marks to go stale: sharedraw.go.42+ frame shareFrame43+ ticking bool44+ // still stops the ticks, so tests that run commands in line don't45+ // wait on them.46+ still bool47+}48+49+// joinReq is a room to join: its key, the file's name as typed, and50+// for a room not yet open, its workbook (nil for a new one) and the51+// file's stamp.52+type joinReq struct {53+ key, name string54+ book *sheet.Workbook55+ stamp stamp56+ // restored is the recovery file book was read from: the room opens57+ // again on it, as unsaved changes.58+ restored string59+}60+61+// roomMsg tells the model its room changed.62+type roomMsg struct{}63+64+// ShareRooms makes a served session open files in the rooms of reg, as65+// user: sessions opening the same file share its workbook.66+func (m *Model) ShareRooms(reg *room.Registry, user string) {67+ m.share.reg = reg68+ m.share.link = &peerLink{name: user, kick: make(chan struct{}, 1)}69+}70+71+// shared reports whether the workbook is shown in a room with others.72+func (m *Model) shared() bool { return m.share.seat != nil && m.share.seat.Others() > 0 }73+74+// roomKey is the room of the file named name: its path on disk, or the75+// name itself for a named room (@name).76+func (m *Model) roomKey(name string) (string, bool) {77+ if strings.HasPrefix(name, "@") {78+ return name, true79+ }80+ p, ok := m.path("open", name)81+ if !ok {82+ return "", false83+ }84+ if abs, err := filepath.Abs(p); err == nil {85+ p = abs86+ }87+ return p, true88+}89+90+// openShared opens the file named name, at path on disk, in its room:91+// joining the room when it's open, reading the file first otherwise.92+func (m *Model) openShared(name, path string) tea.Cmd {93+ key, ok := m.roomKey(name)94+ if !ok {95+ return nil96+ }97+ if m.share.reg.Has(key) {98+ m.share.want = &joinReq{key: key, name: name}99+ return nil100+ }101+ if _, err := os.Stat(path); os.IsNotExist(err) {102+ m.share.want = &joinReq{key: key, name: name, book: sheet.New().Book()}103+ return nil104+ }105+ return loadCmd(name, path, m.spans.Parent())106+}107+108+// joinNamed joins the named room @name, opening it on a new workbook.109+func (m *Model) joinNamed(name string) {110+ m.share.want = &joinReq{key: name}111+}112+113+// loadedShared takes a file read to be opened in its room.114+func (m *Model) loadedShared(msg loadedMsg) {115+ key, ok := m.roomKey(msg.name)116+ if !ok {117+ return118+ }119+ m.share.want = &joinReq{key: key, name: msg.name, book: msg.sheet.Book(), stamp: msg.stamp}120+}121+122+// enterRoom shows the room's workbook, as opening a file does. It runs123+// in a turn of the new seat.124+func (m *Model) enterRoom(seat *room.Seat, created bool, req *joinReq) tea.Cmd {125+ w := seat.Book()126+ s := w.Sheets()[w.Active()]127+ if !s.Live() || s.Hidden() {128+ s = w.Visible()[0]129+ }130+ m.reset(s, req.name)131+ m.share.seat, m.share.seen = seat, seat.Last()132+ m.nb.runs = seat.Value("nb", func() any { return &nbRuns{} }).(*nbRuns)133+ m.src.run = seat.Value("sources", func() any { return &sourceHost{} }).(*sourceHost)134+ switch {135+ case created && req.restored != "":136+ m.disk, m.saved, m.changed, m.recovered = req.stamp, -1, true, req.restored137+ v := m.savedState()138+ v.State = -1139+ seat.SetSaved(v)140+ m.note = "Restored the kept changes; save to keep them"141+ case created:142+ m.disk = req.stamp143+ seat.SetSaved(m.savedState())144+ m.offerRecovery(req.name)145+ default:146+ m.syncSaved()147+ }148+ if peers := seat.Peers(); len(peers) > 0 {149+ m.note = "Sharing " + m.displayName() + " with " + peerNames(peers)150+ }151+ return tea.Batch(m.syncFollowers(), m.notebookSync())152+}153+154+// restoreShared opens the room again on kept changes, when nobody else155+// is in it: others are working on the workbook as it is.156+func (m *Model) restoreShared(seat *room.Seat, msg restoredMsg) {157+ if peers := seat.Peers(); len(peers) > 0 {158+ m.warn = peerNames(peers) + " have " + m.displayName() + " open: restore the kept changes once you're the only one here"159+ return160+ }161+ m.share.want = &joinReq{key: seat.Key(), name: msg.name, book: msg.sheet.Book(), stamp: m.disk, restored: msg.file}162+}163+164+// savedState is the room's file as this model last saved or opened it.165+func (m *Model) savedState() room.Saved {166+ key := ""167+ if m.filename != "" {168+ key, _ = m.roomKey(m.filename)169+ }170+ return room.Saved{State: m.book().StateID(), Outputs: m.book().OutputsChanged(), Name: m.filename, Key: key, Stamp: m.disk}171+}172+173+// syncSaved takes the room's file as anyone last saved it: its name,174+// and what unsaved changes count from.175+func (m *Model) syncSaved() {176+ v := m.share.seat.Saved()177+ m.saved, m.nb.saved = v.State, v.Outputs178+ if st, ok := v.Stamp.(stamp); ok {179+ m.disk = st180+ }181+ if v.Name != m.filename {182+ m.filename = v.Name183+ }184+ m.changed = m.book().StateID() != m.saved || m.book().OutputsChanged() != m.nb.saved185+}186+187+// roomChanged catches up with the room: what was posted for the keeper,188+// others' operations, a save, a sheet deleted from under the view.189+func (m *Model) roomChanged() tea.Cmd {190+ seat := m.share.seat191+ if seat == nil {192+ return nil193+ }194+ var cmds []tea.Cmd195+ for _, msg := range seat.Take() {196+ _, cmd := m.Update(msg)197+ cmds = append(cmds, cmd)198+ }199+ m.othersChanged(seat.Marks(m.share.seen))200+ m.share.seen = seat.Last()201+ m.syncSaved()202+ if !m.sheet.Live() || m.sheet.Hidden() {203+ m.afterSheetsChange(nil, m.book().Active())204+ }205+ m.cur = clampAddr(m.cur)206+ return tea.Batch(cmds...)207+}208+209+// othersChanged says on the context line when someone else changed the210+// cell being typed into, or the one the pointer is on.211+func (m *Model) othersChanged(marks []room.Mark) {212+ seat := m.share.seat213+ for _, mk := range marks {214+ if mk.Author == seat.ID() || mk.Sheet != m.sheet || !mk.Rect.Contains(m.cur) {215+ continue216+ }217+ switch {218+ case m.mode == modeEnter || m.mode == modeEdit:219+ m.warn = mk.Name + " changed " + m.cur.String() + " while you type: Enter puts yours over theirs, Esc keeps theirs"220+ case m.mode == modeReady:221+ m.note = mk.Name + " changed " + m.cur.String()222+ }223+ }224+}225+226+// presence is where the user is, for the others.227+func (m *Model) presence() room.Presence {228+ sel := sheet.Rect{From: m.cur, To: m.cur}229+ if r, ok := m.highlight(); ok {230+ sel = r231+ }232+ return room.Presence{Sheet: m.sheet, Cursor: m.active(), Selection: sel, Editing: m.mode == modeEnter || m.mode == modeEdit}233+}234+235+// mayEdit reports whether the user may change the workbook: always,236+// but in a one-writer room only the writer. When not, it says who237+// writes.238+func (m *Model) mayEdit() bool {239+ seat := m.share.seat240+ if seat == nil || seat.Writing() {241+ return true242+ }243+ m.warn = seat.Writer() + " writes here and you follow: they can hand writing to you"244+ return false245+}246+247+// LeaveRoom gives up the session's seat as it ends, reporting whether248+// it was the last one there (or had a workbook of its own), whose249+// unsaved changes are then its to keep: others keep a room's workbook250+// open. The last one out stops what the room ran.251+func (m *Model) LeaveRoom() bool {252+ seat := m.share.seat253+ if seat == nil {254+ m.closeStreams()255+ return true256+ }257+ m.share.seat = nil258+ if !seat.Leave() {259+ return false260+ }261+ m.closeStreams()262+ return true263+}264+265+// Others is how many others share the session's workbook, and their266+// names.267+func (m *Model) Others() (int, string) {268+ if m.share.seat == nil {269+ return 0, ""270+ }271+ var n int272+ var names string273+ m.share.seat.Do(func(*sheet.Workbook) {274+ peers := m.share.seat.Peers()275+ n, names = len(peers), peerNames(peers)276+ })277+ return n, names278+}279+280+// peerNames lists peers' names: "ann", "ann and bob", "ann, bob and cy".281+func peerNames(peers []room.Peer) string {282+ names := make([]string, len(peers))283+ for i, p := range peers {284+ names[i] = p.Name285+ }286+ switch len(names) {287+ case 0:288+ return ""289+ case 1:290+ return names[0]291+ }292+ return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]293+}294+295+// peerLink is a served session as a room's participant: telling it the296+// room changed kicks a goroutine that sends its program a roomMsg297+// (Shared.Attach).298+type peerLink struct {299+ name string300+ kick chan struct{}301+}302+303+func (p *peerLink) Name() string { return p.name }304+305+func (p *peerLink) Notify() {306+ select {307+ case p.kick <- struct{}{}:308+ default:309+ }310+}@@ -0,0 +1,307 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/config"13+ "github.com/FelineStateMachine/012/internal/confine"14+ "github.com/FelineStateMachine/012/internal/room"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+// seshion is a served session in a room, driven as its program would:19+// every message through Shared.20+type seshion struct {21+ t *testing.T22+ sh *Shared23+ m *Model24+}25+26+// servedRooms is a served directory and the rooms of its sessions.27+type servedRooms struct {28+ t *testing.T29+ dir string30+ root confine.Root31+ reg *room.Registry32+ all []*seshion33+}34+35+func newRooms(t *testing.T, mode room.Mode) *servedRooms {36+ dir := t.TempDir()37+ root, err := confine.New(dir)38+ if err != nil {39+ t.Fatal(err)40+ }41+ return &servedRooms{t: t, dir: dir, root: root, reg: room.NewRegistry(mode)}42+}43+44+// open starts user's session on name, as ssh -t host name does, and45+// catches everyone up.46+func (r *servedRooms) open(user, name string) *seshion {47+ m := New(sheet.New(), "")48+ m.Serve(r.root, []string{"TERM=xterm-256color"})49+ m.ShareRooms(r.reg, user)50+ m.OpenOnStart(name)51+ m.SetMachine("here")52+ m.share.still, m.nb.still = true, true53+ s := &seshion{t: r.t, sh: InRooms(Guard(m)), m: m}54+ s.run(s.sh.Init())55+ s.send(tea.WindowSizeMsg{Width: 80, Height: 20})56+ r.all = append(r.all, s)57+ r.sync()58+ s.sh.turn(func() { s.m.nb.still = true }) // entering the room made its notebook state anew59+ return s60+}61+62+// sync delivers roomMsg to every session its room told, until none is63+// waiting: what Shared.Attach does for a program.64+func (r *servedRooms) sync() {65+ for range 10 {66+ idle := true67+ for _, s := range r.all {68+ select {69+ case <-s.m.share.link.kick:70+ idle = false71+ s.send(roomMsg{})72+ default:73+ }74+ }75+ if idle {76+ return77+ }78+ }79+}80+81+func (s *seshion) update(msg tea.Msg) tea.Cmd {82+ _, cmd := s.sh.Update(msg)83+ return cmd84+}85+86+func (s *seshion) run(cmd tea.Cmd) tea.Msg { return runVia(s.update, cmd) }87+88+func (s *seshion) send(msg tea.Msg) tea.Msg { return s.run(s.update(msg)) }89+90+func (s *seshion) press(keys ...string) tea.Msg {91+ s.t.Helper()92+ return pressTo(s.t, s.send, keys...)93+}94+95+func (s *seshion) screen() string {96+ var out string97+ s.sh.turn(func() { out = ansi.Strip(s.m.View().Content) })98+ return out99+}100+101+// Two sessions opening one file share its workbook: what one types the102+// other sees, where each is shows on the other's screen, and each has103+// its own cursor.104+func TestSharedSessionsSeeEachOther(t *testing.T) {105+ r := newRooms(t, room.Edit)106+ writeSheet(t, filepath.Join(r.dir, "budget.012"), "rent")107+ ann := r.open("ann", "budget.012")108+ bob := r.open("bob", "budget.012")109+ if ann.m.book() != bob.m.book() {110+ t.Fatal("two workbooks")111+ }112+ if !strings.Contains(bob.m.note, "Sharing budget.012 with ann") {113+ t.Errorf("bob's note %q", bob.m.note)114+ }115+ ann.press("<down>", "1200", "<enter>")116+ r.sync()117+ if got := bob.m.sheet.Value(addr("A2")).Num; got != 1200 {118+ t.Fatalf("bob sees A2 = %v", got)119+ }120+ if !strings.Contains(bob.screen(), "1200") {121+ t.Errorf("bob's screen:\n%s", bob.screen())122+ }123+ bob.press("<right>", "<right>")124+ r.sync()125+ if bob.m.cur != addr("C1") || ann.m.cur != addr("A3") {126+ t.Errorf("cursors: ann %v, bob %v", ann.m.cur, bob.m.cur)127+ }128+ scr := ann.screen()129+ if !strings.Contains(scr, "b 1 rent") {130+ t.Errorf("bob's initial isn't on row 1's header:\n%s", scr)131+ }132+ if !strings.Contains(scr, " bob ") {133+ t.Errorf("the status line doesn't list bob:\n%s", scr)134+ }135+ // Ann goes to bob's cell from Who's here.136+ run(ann.m, nil)137+ ann.run(ann.m.runCommand("share.who"))138+ ann.press("<enter>")139+ ann.screen() // a frame, which works out where the others are140+ if ann.m.cur != addr("C1") || !strings.Contains(ansi.Strip(ann.m.shareLine()), "bob is here") {141+ t.Errorf("ann at %v:\n%s", ann.m.cur, ann.screen())142+ }143+}144+145+// Undo takes back one's own changes, and says whose later change is in146+// the way when it can't.147+func TestSharedUndoIsYourOwn(t *testing.T) {148+ r := newRooms(t, room.Edit)149+ ann := r.open("ann", "@plan")150+ bob := r.open("bob", "@plan")151+ ann.press("mine", "<enter>")152+ r.sync()153+ bob.press("<right>", "theirs", "<enter>")154+ r.sync()155+ ann.press("<ctrl+z>")156+ r.sync()157+ s := ann.m.sheet158+ if s.Filled(addr("A1")) || s.Value(addr("B1")).Str != "theirs" {159+ t.Fatalf("after ann's undo: A1 %v, B1 %v", s.Value(addr("A1")), s.Value(addr("B1")))160+ }161+ if !strings.Contains(bob.screen(), "theirs") || strings.Contains(bob.screen(), "mine") {162+ t.Errorf("bob's screen:\n%s", bob.screen())163+ }164+ ann.press("<ctrl+y>")165+ r.sync()166+ bob.press("<left>", "<up>", "bob's", "<enter>")167+ r.sync()168+ ann.press("<ctrl+z>")169+ if !strings.Contains(ann.m.warn, "Can't undo") || !strings.Contains(ann.m.warn, "bob") || s.Value(addr("A1")).Str != "bob's" {170+ t.Errorf("ann's undo: %q, A1 %v", ann.m.warn, s.Value(addr("A1")))171+ }172+ if ann.m.displayName() != "@plan" {173+ t.Errorf("named %q", ann.m.displayName())174+ }175+}176+177+// An entry typed over a cell someone else changed meanwhile goes in, in178+// the server's order, and both are told.179+func TestSharedConflictingEntries(t *testing.T) {180+ r := newRooms(t, room.Edit)181+ ann := r.open("ann", "@c")182+ bob := r.open("bob", "@c")183+ ann.press("a", "nn")184+ bob.press("bob", "<enter>")185+ r.sync()186+ if !strings.Contains(ann.m.warn, "bob changed A1 while you type") {187+ t.Errorf("ann wasn't told: %q", ann.m.warn)188+ }189+ ann.press("<enter>")190+ r.sync()191+ if ann.m.sheet.Value(addr("A1")).Str != "ann" || !strings.Contains(ann.m.warn, "replaced bob's change to A1") {192+ t.Errorf("A1 %v, ann's warning %q", ann.m.sheet.Value(addr("A1")), ann.m.warn)193+ }194+ bob.press("<up>")195+ if !strings.Contains(bob.screen(), "Changed by") {196+ t.Errorf("bob's context line:\n%s", bob.screen())197+ }198+}199+200+// Saving is the room's: a save by one clears the others' modified mark,201+// and quitting while others remain asks nothing.202+func TestSharedSaveAndQuit(t *testing.T) {203+ r := newRooms(t, room.Edit)204+ ann := r.open("ann", "notes.012")205+ bob := r.open("bob", "notes.012")206+ bob.press("x", "<enter>")207+ r.sync()208+ if !ann.m.changed || !bob.m.changed {209+ t.Fatalf("modified: ann %v, bob %v", ann.m.changed, bob.m.changed)210+ }211+ ann.press("<ctrl+s>")212+ r.sync()213+ if ann.m.changed || bob.m.changed {214+ t.Errorf("after ann saved, modified: ann %v, bob %v", ann.m.changed, bob.m.changed)215+ }216+ if _, err := os.Stat(filepath.Join(r.dir, "notes.012")); err != nil {217+ t.Fatal(err)218+ }219+ bob.press("y", "<enter>")220+ r.sync()221+ if msg := ann.press("<ctrl+q>"); msg == nil {222+ t.Errorf("ann asked before quitting, bob still there: %q", ann.screen())223+ }224+ if ann.m.LeaveRoom() {225+ t.Error("ann was the last")226+ }227+ r.sync()228+ if !bob.m.LeaveRoom() || !bob.m.Unsaved() {229+ t.Error("bob, last, doesn't keep his unsaved change")230+ }231+}232+233+// In a one-writer room the others follow: they can't edit until the234+// writer hands writing over.235+func TestSharedOneWriter(t *testing.T) {236+ r := newRooms(t, room.View)237+ ann := r.open("ann", "@talk")238+ bob := r.open("bob", "@talk")239+ bob.press("hello")240+ if bob.m.mode != modeReady || !strings.Contains(bob.m.warn, "ann writes here and you follow") {241+ t.Fatalf("bob typed: mode %v, %q", bob.m.mode, bob.m.warn)242+ }243+ if !strings.Contains(bob.screen(), "ann writes") {244+ t.Errorf("bob's status line:\n%s", bob.screen())245+ }246+ ann.run(ann.m.runCommand("share.hand"))247+ ann.press("<enter>")248+ r.sync()249+ bob.press("hello", "<enter>")250+ if bob.m.sheet.Value(addr("A1")).Str != "hello" {251+ t.Errorf("bob, writing, couldn't type: %q", bob.m.warn)252+ }253+ ann.press("<down>", "no", "<enter>")254+ if ann.m.sheet.Filled(addr("A2")) {255+ t.Error("ann typed after handing over")256+ }257+}258+259+// shell lets s's notebook cells run, with a fake nu answering from out.260+func (s *seshion) shell(out map[string]string) *fakeNu {261+ path := filepath.Join(s.t.TempDir(), "config")262+ os.WriteFile(path, []byte("serve-shell = true\nshell = on\n"), 0o600)263+ load := func() *config.Config { return config.Load(path, func(string) string { return "" }, nil) }264+ nu := &fakeNu{out: out}265+ s.sh.turn(func() {266+ s.m.Configure(Settings{Config: load(), Reload: load})267+ s.m.SetShellRunner(nu)268+ })269+ return nu270+}271+272+// A shared notebook's runs are the room's: whoever runs a cell, the273+// one keeping the room finishes it, everyone sees its output and count,274+// and they go on once the one who started them leaves.275+func TestSharedNotebookRuns(t *testing.T) {276+ r := newRooms(t, room.Edit)277+ ann := r.open("ann", "@nb")278+ bob := r.open("bob", "@nb")279+ out := map[string]string{"ls": lsOut}280+ ann.shell(out)281+ bob.shell(out)282+ ann.run(ann.m.runCommand("nb.open"))283+ r.sync()284+ ann.press("<esc>", "b", "<enter>")285+ ann.send(pasteMsg("files = ls"))286+ ann.press("<esc>", "<ctrl+enter>")287+ r.sync()288+ bob.run(bob.m.runCommand("nb.open"))289+ if !strings.Contains(bob.screen(), "a.txt") || !strings.Contains(bob.screen(), "Out[1]:") {290+ t.Fatalf("bob's notebook:\n%s", bob.screen())291+ }292+ bob.press("<down>", "<ctrl+enter>")293+ r.sync()294+ if !strings.Contains(ann.screen(), "Out[2]:") || !strings.Contains(bob.screen(), "Out[2]:") {295+ t.Errorf("bob's run, finished by ann who keeps the room:\n%s", ann.screen())296+ }297+ if ann.m.LeaveRoom() {298+ t.Fatal("ann was the last")299+ }300+ r.all = r.all[1:]301+ r.sync()302+ bob.press("<ctrl+enter>")303+ r.sync()304+ if !strings.Contains(bob.screen(), "Out[3]:") {305+ t.Errorf("bob, keeping the room now, didn't finish his run:\n%s", bob.screen())306+ }307+}@@ -0,0 +1,178 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/room"9+ "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+)12+13+// The commands of a shared workbook: who's here, and handing writing14+// over in a one-writer room; what undo says when another's step is in15+// the way; and whose change an entry replaced.16+17+func init() {18+ unshared := func(m *Model) bool { return m.share.reg == nil }19+ register(20+ &command{id: "share.who", macro: macroNever, title: "Who's here", desc: "List who shares this workbook and where each is; pick one to go to their cell",21+ hidden: unshared, enabled: func(m *Model) bool { return m.shared() }, run: (*Model).openWho},22+ &command{id: "share.hand", macro: macroNever, title: "Hand over writing", desc: "Let someone else edit the shared workbook, and follow them",23+ hidden: unshared, enabled: func(m *Model) bool {24+ seat := m.share.seat25+ return seat != nil && seat.Mode() == room.View && seat.Writing() && seat.Others() > 026+ }, run: (*Model).openHandOver},27+ )28+}29+30+// nbReading are the notebook's commands that change nothing a follower31+// in a one-writer room may not: moving about and looking.32+var nbReading = map[string]bool{"nb.open": true, "nb.goto": true, "nb.toc": true, "nb.toggle_output": true, "nb.toggle_whole": true,33+ "nb.open_output": true, "nb.command_mode": true, "nb.copy": true}34+35+// edits reports whether command c changes the workbook, which a36+// follower in a one-writer room may not do.37+func edits(c *command) bool {38+ if strings.HasPrefix(c.id, "nb.") {39+ return !nbReading[c.id]40+ }41+ return c.macro == macroRecord42+}43+44+// openWho lists who shares the workbook, to go to one of them.45+func (m *Model) openWho() tea.Cmd {46+ var items []picker.Item47+ for _, p := range m.share.seat.Peers() {48+ where := m.peerWhere(p)49+ desc := "Go to " + p.Name + "'s cell"50+ detail := where51+ switch {52+ case p.Writing && m.share.seat.Mode() == room.View:53+ detail += ", writing"54+ case p.Presence.Editing:55+ detail += ", typing"56+ }57+ items = append(items, picker.Item{Title: p.Name, Name: len(p.Name), Detail: detail, Desc: desc,58+ Pick: func() tea.Cmd { m.closeOverlay(); m.goToPeer(p); return nil }})59+ }60+ pk := m.newPicker("Who's here", "a name", 50, items)61+ pk.Action = "go"62+ m.openOverlay(pk)63+ return nil64+}65+66+// peerWhere is where p is, Sheet2!B3, or the notebook they're on.67+func (m *Model) peerWhere(p room.Peer) string {68+ s := p.Presence.Sheet69+ if s == nil || !s.Live() {70+ return "arriving"71+ }72+ if s.IsNotebook() {73+ return s.Name()74+ }75+ return sheet.Qualified(s.Name(), sheet.Rect{From: p.Presence.Cursor, To: p.Presence.Cursor})76+}77+78+// goToPeer shows p's sheet with the pointer on their cell.79+func (m *Model) goToPeer(p room.Peer) {80+ s := p.Presence.Sheet81+ if s == nil || !s.Live() || s.Hidden() {82+ return83+ }84+ m.showSheet(s)85+ if !s.IsNotebook() {86+ m.cur = p.Presence.Cursor87+ m.clearSelection()88+ }89+ m.note = "At " + p.Name + "'s cell"90+}91+92+// openHandOver asks whom to hand writing to.93+func (m *Model) openHandOver() tea.Cmd {94+ var items []picker.Item95+ for _, p := range m.share.seat.Peers() {96+ items = append(items, picker.Item{Title: p.Name, Name: len(p.Name), Detail: m.peerWhere(p), Desc: "Let " + p.Name + " write; you follow",97+ Pick: func() tea.Cmd {98+ m.closeOverlay()99+ if m.share.seat.HandTo(p.ID) {100+ m.note = p.Name + " writes now: you follow"101+ }102+ return nil103+ }})104+ }105+ pk := m.newPicker("Hand over writing", "a name", 50, items)106+ pk.Action = "hand over"107+ m.openOverlay(pk)108+ return nil109+}110+111+// undoBlocked says why the user's undo (or redo) can't go ahead: a later112+// change of someone else's changed the same cells.113+func (m *Model) undoBlocked(redo bool) bool {114+ if m.share.seat == nil {115+ return false116+ }117+ w := m.book()118+ check, verb := w.UndoBlocked, "undo "+w.UndoLabel()119+ if redo {120+ check, verb = w.RedoBlocked, "redo"121+ }122+ b, blocked := check()123+ if !blocked {124+ return false125+ }126+ m.warn = "Can't " + verb + ": " + m.peerName(b.Author) + " has changed it since, and undo takes back only your own changes"127+ return true128+}129+130+// peerName is the name of the participant with id, or "someone" when131+// they've left.132+func (m *Model) peerName(id int) string {133+ for _, p := range m.share.seat.Peers() {134+ if p.ID == id {135+ return p.Name136+ }137+ }138+ return "someone who left"139+}140+141+// roomLast is the room's latest operation, 0 outside a room.142+func (m *Model) roomLast() uint64 {143+ if m.share.seat == nil {144+ return 0145+ }146+ return m.share.seat.Last()147+}148+149+// overwrote says whose change the entry just made replaced: someone150+// else changed the cell while it was being typed. The server's order151+// decides, and the last entry stays.152+func (m *Model) overwrote() {153+ seat := m.share.seat154+ if seat == nil {155+ return156+ }157+ for _, mk := range seat.Marks(m.share.entryFrom) {158+ if mk.Author != seat.ID() && mk.Sheet == m.entrySheet() && mk.Rect.Contains(m.cur) && mk.Kind != sheet.OpUndo {159+ m.warn = "Your entry replaced " + mk.Name + "'s change to " + m.cur.String() + ", made while you typed"160+ }161+ }162+}163+164+// roomOwned has what cmd reports go to the room's keeper when the165+// workbook is shared, rather than back to this model, which may have166+// left by then.167+func (m *Model) roomOwned(cmd tea.Cmd) tea.Cmd {168+ seat := m.share.seat169+ if seat == nil || cmd == nil {170+ return cmd171+ }172+ return func() tea.Msg {173+ if msg := cmd(); msg != nil {174+ seat.Post(msg)175+ }176+ return nil177+ }178+}@@ -0,0 +1,186 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+ "time"7+8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "github.com/FelineStateMachine/012/internal/room"13+ "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// Drawing who else is in a shared workbook, and what they changed: each18+// other participant's pointer is their cell in their color,19+// double-underlined, with their initial in the row header; a cell they20+// changed in the last markFresh has a ▘ in its corner in their color;21+// the status line lists who's here, and the context line says who is on22+// the active cell or last changed it. Colors and cues are theme.Peer.23+24+// markFresh is how long a cell another changed stays marked.25+const markFresh = 30 * time.Second26+27+// shareFrame is what a frame draws of the others, worked out once as it28+// starts: their pointers on the sheet shown, and the fresh marks.29+type shareFrame struct {30+ peers []room.Peer31+ here []room.Peer // on the sheet shown32+ fresh []room.Mark // others' changes on the sheet shown, newest last33+}34+35+// shareTickMsg redraws once marks have gone stale.36+type shareTickMsg struct{}37+38+// frameShare works out the others for the frame about to be drawn.39+func (m *Model) frameShare() {40+ f := &m.share.frame41+ f.peers, f.here, f.fresh = nil, f.here[:0], f.fresh[:0]42+ seat := m.share.seat43+ if seat == nil {44+ return45+ }46+ f.peers = seat.Peers()47+ for _, p := range f.peers {48+ if p.Presence.Sheet == m.sheet {49+ f.here = append(f.here, p)50+ }51+ }52+ now := time.Now()53+ for _, mk := range seat.Marks(0) {54+ if mk.Author != seat.ID() && mk.Sheet == m.sheet && now.Sub(mk.At) < markFresh && mk.Kind != sheet.OpUndo {55+ f.fresh = append(f.fresh, mk)56+ }57+ }58+}59+60+// peerAt is the other whose pointer is on a, if one is.61+func (m *Model) peerAt(a sheet.Addr) (room.Peer, bool) {62+ for _, p := range m.share.frame.here {63+ if p.Presence.Cursor == a {64+ return p, true65+ }66+ }67+ return room.Peer{}, false68+}69+70+// peerRole is the role of a cell under another's pointer.71+func (m *Model) peerRole(a sheet.Addr) (*lipgloss.Style, bool) {72+ p, ok := m.peerAt(a)73+ if !ok {74+ return nil, false75+ }76+ st := m.th.PeerCell(p.Color)77+ return &st, true78+}79+80+// freshMark is the newest of others' fresh changes covering a.81+func (m *Model) freshMark(a sheet.Addr) (room.Mark, bool) {82+ f := m.share.frame.fresh83+ for i := len(f) - 1; i >= 0; i-- {84+ if f[i].Rect.Contains(a) {85+ return f[i], true86+ }87+ }88+ return room.Mark{}, false89+}90+91+// changedMark draws the mark of a cell another changed lately over the92+// first column of its text.93+func (m *Model) changedMark(text string, a sheet.Addr, base lipgloss.Style, colored bool) string {94+ mk, ok := m.freshMark(a)95+ if !ok || m.sheet.ColWidth(a.Col) < 2 {96+ return text97+ }98+ mark := m.th.PeerMark[mk.Color%theme.Peers]99+ if colored {100+ mark = base101+ }102+ return mark.Render("▘") + ansi.Cut(text, 1, m.sheet.ColWidth(a.Col))103+}104+105+// rowTags are the initials of those whose pointer is on row, as chips106+// in their colors, and how many columns they take.107+func (m *Model) rowTags(row int) (string, int) {108+ var b strings.Builder109+ n := 0110+ for _, p := range m.share.frame.here {111+ if p.Presence.Cursor.Row != row || n >= 2 {112+ continue113+ }114+ b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(initial(p.Name)))115+ n++116+ }117+ return b.String(), n118+}119+120+// initial is a name's first letter.121+func initial(name string) string {122+ for _, r := range name {123+ return string(r)124+ }125+ return "?"126+}127+128+// peersStatus is the status line's part about the others: their names129+// in their colors, and in a one-writer room who writes.130+func (m *Model) peersStatus() string {131+ peers := m.share.frame.peers132+ if len(peers) == 0 {133+ return ""134+ }135+ var b strings.Builder136+ b.WriteString(" ")137+ for i, p := range peers {138+ if i == 3 {139+ b.WriteString(m.th.Muted.Render(" +" + strconv.Itoa(len(peers)-3)))140+ break141+ }142+ if i > 0 {143+ b.WriteString(" ")144+ }145+ b.WriteString(m.th.Peer[p.Color%theme.Peers].Render(" " + ansi.Truncate(p.Name, 10, "…") + " "))146+ }147+ if seat := m.share.seat; seat.Mode() == room.View {148+ if seat.Writing() {149+ b.WriteString(m.th.Muted.Render(" you write"))150+ } else {151+ b.WriteString(m.th.Muted.Render(" " + seat.Writer() + " writes"))152+ }153+ }154+ return b.String()155+}156+157+// shareLine is the context line's word on the active cell: who else is158+// on it, or who changed it lately.159+func (m *Model) shareLine() string {160+ if m.share.seat == nil {161+ return ""162+ }163+ if p, ok := m.peerAt(m.cur); ok {164+ what := " is here"165+ if p.Presence.Editing {166+ what = " is typing here"167+ }168+ return m.th.Peer[p.Color%theme.Peers].Render(" "+p.Name+" ") + m.th.Muted.Render(what)169+ }170+ if mk, ok := m.freshMark(m.cur); ok {171+ ago := time.Since(mk.At).Round(time.Second)172+ return m.th.Muted.Render("Changed by ") + m.th.Peer[mk.Color%theme.Peers].Render(" "+mk.Name+" ") +173+ m.th.Muted.Render(" "+ago.String()+" ago: "+mk.Label)174+ }175+ return ""176+}177+178+// shareTick redraws once the freshest mark has gone stale, while there179+// are marks.180+func (m *Model) shareTick() tea.Cmd {181+ if len(m.share.frame.fresh) == 0 || m.share.ticking || m.share.still {182+ return nil183+ }184+ m.share.ticking = true185+ return tea.Tick(markFresh, func(time.Time) tea.Msg { return shareTickMsg{} })186+}@@ -0,0 +1,157 @@
1+package ui2+3+import (4+ "context"5+ "io"6+ "slices"7+ "strings"8+ "sync"9+ "testing"10+ "time"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/charmbracelet/x/ansi"14+15+ "github.com/FelineStateMachine/012/internal/room"16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// watched is a program's model that notes when a frame first shows a20+// text: a session watching another's edit arrive.21+type watched struct {22+ *Shared23+ mu sync.Mutex24+ want string25+ seen chan time.Time26+}27+28+func (w *watched) Update(msg tea.Msg) (tea.Model, tea.Cmd) {29+ _, cmd := w.Shared.Update(msg)30+ return w, cmd31+}32+33+func (w *watched) View() tea.View {34+ v := w.Shared.View()35+ w.mu.Lock()36+ defer w.mu.Unlock()37+ if w.want != "" && strings.Contains(ansi.Strip(v.Content), w.want) {38+ w.want = ""39+ w.seen <- time.Now()40+ }41+ return v42+}43+44+// expect has the next frame showing text noted.45+func (w *watched) expect(text string) {46+ w.mu.Lock()47+ defer w.mu.Unlock()48+ w.want = text49+}50+51+// program runs a served session in a room in a real Bubble Tea program,52+// with no terminal, until the test ends.53+func program(t *testing.T, r *servedRooms, user, name string) (*watched, *tea.Program) {54+ m := New(sheet.New(), "")55+ m.Serve(r.root, []string{"TERM=xterm-256color"})56+ m.ShareRooms(r.reg, user)57+ m.OpenOnStart(name)58+ w := &watched{Shared: InRooms(Guard(m)), seen: make(chan time.Time, 1)}59+ p := tea.NewProgram(w, tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutSignals(), tea.WithWindowSize(80, 24), tea.WithFPS(FrameRate))60+ ctx, cancel := context.WithCancel(context.Background())61+ w.Attach(ctx, p)62+ done := make(chan struct{})63+ go func() {64+ p.Run()65+ close(done)66+ }()67+ t.Cleanup(func() {68+ p.Quit()69+ <-done70+ cancel()71+ })72+ return w, p73+}74+75+// An edit in one session reaches another's screen within a frame: the76+// other's model has taken it and drawn a frame showing it before a77+// frame's time has passed, so the next frame its terminal gets holds it.78+func TestSharedEditWithinAFrame(t *testing.T) {79+ if testing.Short() {80+ t.Skip("times frames")81+ }82+ unlock := benchLock(t)83+ defer unlock()84+ r := newRooms(t, room.Edit)85+ ann := r.open("ann", "@fast")86+ bob, _ := program(t, r, "bob", "@fast")87+ deadline := time.Now().Add(5 * time.Second)88+ for shared := false; !shared && time.Now().Before(deadline); {89+ ann.sh.turn(func() { shared = ann.m.shared() })90+ time.Sleep(time.Millisecond)91+ }92+ var took []time.Duration93+ for i := range 30 {94+ text := "v" + strings.Repeat("x", i%5) + string(rune('a'+i%26))95+ ann.press(text)96+ bob.expect(text)97+ start := time.Now()98+ ann.press("<enter>")99+ select {100+ case at := <-bob.seen:101+ took = append(took, at.Sub(start))102+ case <-time.After(2 * time.Second):103+ t.Fatalf("bob never showed %q", text)104+ }105+ ann.press("<up>") // the same cell again, on bob's screen106+ }107+ slices.Sort(took)108+ median := took[len(took)/2]109+ t.Logf("an edit reached the other session in %v (median), %v at worst", median, took[len(took)-1])110+ if median > FrameInterval {111+ t.Errorf("median %v, more than a frame (%v)", median, FrameInterval)112+ }113+}114+115+// Sessions typing at once in one room, each in its own program: every116+// entry lands, and the race detector finds nothing shared unguarded.117+func TestSharedSessionsTypeAtOnce(t *testing.T) {118+ r := newRooms(t, room.Edit)119+ var ps []*tea.Program120+ for _, user := range []string{"ann", "bob", "cy"} {121+ _, p := program(t, r, user, "@busy")122+ ps = append(ps, p)123+ }124+ time.Sleep(50 * time.Millisecond) // everyone's in125+ var wg sync.WaitGroup126+ for i, p := range ps {127+ wg.Add(1)128+ go func() {129+ defer wg.Done()130+ for k := range i + 1 { // each to its own column131+ p.Send(tea.KeyPressMsg{Code: tea.KeyRight})132+ _ = k133+ }134+ for row := range 20 {135+ for _, c := range "n" + string(rune('0'+row%10)) {136+ p.Send(tea.KeyPressMsg{Code: c, Text: string(c)})137+ }138+ p.Send(tea.KeyPressMsg{Code: tea.KeyEnter})139+ }140+ }()141+ }142+ wg.Wait()143+ // Look as one more participant would, in a turn of its own.144+ look, _ := r.reg.Join("@busy", &peerLink{name: "look", kick: make(chan struct{}, 1)}, nil)145+ deadline := time.Now().Add(5 * time.Second)146+ for {147+ n := 0148+ look.Do(func(w *sheet.Workbook) { n = w.Sheets()[0].Len() })149+ if n == 60 {150+ break151+ }152+ if time.Now().After(deadline) {153+ t.Fatalf("%d of 60 entries landed", n)154+ }155+ time.Sleep(10 * time.Millisecond)156+ }157+}@@ -0,0 +1,36 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/012/internal/room"8+)9+10+// Others read without color: their pointer's cell double-underlined,11+// their initial on its row's header, their name on the status line,12+// and a cell they just changed marked with ▘.13+func TestMonochromePeers(t *testing.T) {14+ r := newRooms(t, room.Edit)15+ ann := r.open("ann", "@mono")16+ bob := r.open("bob", "@mono")17+ bob.press("<down>", "x", "<enter>", "<right>")18+ r.sync()19+ ann.sh.turn(func() {20+ m := ann.m21+ m.frameShare()22+ c := cellAt(m, addr("B3"))23+ if !every(c, true, func(c monoCell) bool { return c.underline == "2" }) {24+ t.Errorf("bob's cell: %+v", c)25+ }26+ if row := cellText(monoLine(m, gridTop+2)); !strings.HasPrefix(row, "b") {27+ t.Errorf("row 3's header %q", row)28+ }29+ if row := cellText(monoLine(m, gridTop+1)); !strings.Contains(row, "▘x") {30+ t.Errorf("row 2 %q", row)31+ }32+ if !strings.Contains(cellText(monoLine(m, m.height-1)), " bob ") {33+ t.Error("bob isn't on the status line")34+ }35+ })36+}@@ -24,16 +24,28 @@ import (
2424 // reconciled with the workbook after every update (syncSources), as2525 // linked files are (follow.go), and their files are looked at every2626 // live.Interval, a source read again once its file has changed.27+//28+// In a room of 012 serve the host is the room's, as its linked files29+// and notebook runs are (share.go): whoever keeps the room links the30+// sources, runs the jobs and looks at the files, and the jobs' answers31+// reach whoever keeps it when they're done (roomOwned). Every32+// participant reads the pages of the tab it shows itself.33+34+// sourceHost is a workbook's host and the jobs it has queued: the35+// model's own, or the room's.36+type sourceHost struct {37+ host *paged.Host38+ links string // the links given to the host, to notice a change39+ failed map[string]string // why a source can't be linked, as told to the workbook40+ queue []*paged.Job // the host's jobs waiting for a turn41+ running int42+}2743 2844 // sourceState is the linked sources' part of the model.2945 type sourceState struct {30- host *paged.Host31- links string // the links given to the host, to notice a change32- failed map[string]string // why a source can't be linked, as told to the workbook46+ run *sourceHost3347 pages map[string]*paged.Pages // the rows of each source's tab, by the source's name3448 views map[*sheet.Sheet]*srcview.View // each source tab's view35- queue []*paged.Job // the host's jobs waiting for a turn36- running int3749 ticking bool3850 dragBar bool // the scrollbar's thumb is being dragged3951 }@@ -58,10 +70,26 @@ type (
5870 sourceTickMsg struct{ host *paged.Host }5971 )6072 61-// closeSources lets the workbook's sources go, as a new one is opened.73+// sources is the workbook's host and jobs.74+func (m *Model) sources() *sourceHost {75+ if m.src.run == nil {76+ m.src.run = &sourceHost{}77+ }78+ return m.src.run79+}80+81+// keepsSources reports whether this model runs the host's jobs: its own82+// workbook's, or a room's it keeps.83+func (m *Model) keepsSources() bool {84+ seat := m.share.seat85+ return seat == nil || seat.Keeper()86+}87+88+// closeSources lets the workbook's sources go, as another is opened:89+// the host too, unless others share it.6290 func (m *Model) closeSources() {63- if m.src.host != nil {64- m.src.host.Close()91+ if r := m.src.run; r != nil && r.host != nil && !m.shared() {92+ r.host.Close()6593 }6694 for _, p := range m.src.pages {6795 p.Close()@@ -71,25 +99,31 @@ func (m *Model) closeSources() {
7199 // syncSources gives the host the workbook's sources, keeps the pages of72100 // the source shown, and runs what's queued.73101 func (m *Model) syncSources() tea.Cmd {74- w := m.book()102+ w, r := m.book(), m.sources()75103 infos := w.Sources()76- if len(infos) == 0 && m.src.host == nil {104+ if len(infos) == 0 && r.host == nil {77105 return nil78106 }79- if m.src.host == nil {80- m.src.host = paged.NewHost(sheet.MaxCells())81- w.SetSources(m.src.host)107+ if r.host == nil {108+ r.host = paged.NewHost(sheet.MaxCells())109+ w.SetSources(r.host)110+ }111+ keeper := m.keepsSources()112+ if keeper {113+ m.linkSources(infos)82114 }83- m.linkSources(infos)84115 m.syncPages()85116 if v := m.srcView(); v != nil {86117 v.Prefetch()87118 }88- cmds := []tea.Cmd{m.runSourceJobs(), m.runPageJobs()}89- if !m.src.ticking && len(infos) > 0 {90- m.src.ticking = true91- h := m.src.host92- cmds = append(cmds, tea.Tick(sourceInterval, func(time.Time) tea.Msg { return sourceTickMsg{h} }))119+ cmds := []tea.Cmd{m.runPageJobs()}120+ if keeper {121+ cmds = append(cmds, m.runSourceJobs())122+ if !m.src.ticking && len(infos) > 0 {123+ m.src.ticking = true124+ h := r.host125+ cmds = append(cmds, tea.Tick(sourceInterval, func(time.Time) tea.Msg { return sourceTickMsg{h} }))126+ }93127 }94128 return tea.Batch(cmds...)95129 }@@ -97,29 +131,30 @@ func (m *Model) syncSources() tea.Cmd {
97131 // linkSources gives the host the sources it may read, their files98132 // resolved, and tells the workbook why the others can't be.99133 func (m *Model) linkSources(infos []sheet.SourceInfo) {134+ r := m.sources()100135 var links []paged.Linked101136 var key strings.Builder102137 for _, info := range infos {103138 src := info.Source104139 path, why := m.sourcePath(src)105140 if why != "" {106- if m.src.failed[info.Name] != why {107- if m.src.failed == nil {108- m.src.failed = map[string]string{}141+ if r.failed[info.Name] != why {142+ if r.failed == nil {143+ r.failed = map[string]string{}109144 }110- m.src.failed[info.Name] = why145+ r.failed[info.Name] = why111146 m.book().SetSourceShape(info.Name, sheet.SourceShape{}, why)112147 }113148 continue114149 }115- delete(m.src.failed, info.Name)150+ delete(r.failed, info.Name)116151 spec := fileio.SourceSpec{Path: path, Format: src.Format, Table: src.Table, Query: src.Query}117152 links = append(links, paged.Linked{Name: info.Name, Spec: spec})118153 key.WriteString(info.Name + "\x00" + path + "\x00" + src.Format + "\x00" + src.Table + "\x00" + src.Query + "\x01")119154 }120- if key.String() != m.src.links {121- m.src.links = key.String()122- m.src.host.Link(links)155+ if key.String() != r.links {156+ r.links = key.String()157+ r.host.Link(links)123158 }124159 }125160 @@ -140,20 +175,21 @@ func (m *Model) sourcePath(src sheet.LinkSource) (string, string) {
140175 141176 // runSourceJobs starts the host's jobs, a few at a time.142177 func (m *Model) runSourceJobs() tea.Cmd {143- h := m.src.host144- m.src.queue = append(m.src.queue, h.Jobs()...)178+ r := m.sources()179+ h := r.host180+ r.queue = append(r.queue, h.Jobs()...)145181 var cmds []tea.Cmd146- for m.src.running < sourceParallel && len(m.src.queue) > 0 {147- j := m.src.queue[0]148- m.src.queue = m.src.queue[1:]149- m.src.running++182+ for r.running < sourceParallel && len(r.queue) > 0 {183+ j := r.queue[0]184+ r.queue = r.queue[1:]185+ r.running++150186 parent := m.spans.Parent()151- cmds = append(cmds, func() tea.Msg {187+ cmds = append(cmds, m.roomOwned(func() tea.Msg {152188 span := parent.Start("source " + j.String())153189 j.Run(context.Background())154190 span.End()155191 return sourceJobMsg{h, j}156- })192+ }))157193 }158194 return tea.Batch(cmds...)159195 }@@ -174,23 +210,23 @@ func (m *Model) runPageJobs() tea.Cmd {
174210 175211 // handleSource takes what a job found, a page read, or a tick.176212 func (m *Model) handleSource(msg tea.Msg) tea.Cmd {213+ r := m.sources()177214 switch msg := msg.(type) {178215 case sourceJobMsg:179- if msg.host != m.src.host {216+ if msg.host != r.host {180217 return nil // the workbook it was for is gone181218 }182- m.src.running--183- paged.Apply(m.book(), m.src.host.Store(msg.j))219+ r.running--220+ paged.Apply(m.book(), r.host.Store(msg.j))184221 case sourcePageMsg:185222 if m.src.pages[pagesName(m.src.pages, msg.p)] == msg.p {186223 msg.p.Store(msg.j)187224 }188225 case sourceTickMsg:189- if msg.host != m.src.host {190- return nil191- }192226 m.src.ticking = false193- m.src.host.Poll()227+ if msg.host == r.host && m.keepsSources() {228+ r.host.Poll()229+ }194230 }195231 return nil196232 }@@ -219,7 +255,7 @@ func (m *Model) syncPages() {
219255 if !ok {220256 return221257 }222- h, gen, open := m.src.host.Source(info.Name)258+ h, gen, open := m.sources().host.Source(info.Name)223259 p := m.src.pages[info.Name]224260 switch {225261 case !open:@@ -228,6 +264,7 @@ func (m *Model) syncPages() {
228264 delete(m.src.pages, info.Name)229265 }230266 case p == nil || !p.Same(h, gen, info.Source.Order):267+ reordered := p != nil && p.Of(h, gen)231268 if p != nil {232269 p.Close()233270 }@@ -236,7 +273,9 @@ func (m *Model) syncPages() {
236273 }237274 m.src.pages[info.Name] = paged.NewPages(h, gen, info.Source.Order)238275 if v := m.src.views[m.sheet]; v != nil {239- v.Reset()276+ if reordered {277+ v.Reset() // other rows in other places: back to the first278+ }240279 v.Refit()241280 }242281 }Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.