- commit
- 35535fd9c494a86a91c076fa237f49d53b11f2bb
- parent
- 19676c348767318a8bc381294a06ed37d7a39210
- author
- Dami <dami.etoile@gmail.com>
| M | ROADMAP.md | 6 | +- |
| M | docs/ | 16 | +- |
| M | docs/ | 10 | +- |
| M | docs/ | 87 | +++++++-- |
| M | e2e/ | 8 | +- |
| A | e2e/ | 43 | ++++ |
| M | e2e/ | 24 | +-- |
| M | e2e/ | 40 | +++- |
| M | e2e/ | 21 | ++ |
| A | e2e/ | 30 | +++ |
| A | internal/ | 86 | +++++++++ |
| A | internal/ | 259 | ++++++++++++++++++++++++++ |
| M | internal/ | 3 | +- |
| M | internal/ | 155 | +++++++++++++-- |
| M | internal/ | 2 | + |
| M | internal/ | 6 | +- |
| A | internal/ | 301 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 177 | ++++++++++++++++++ |
@@ -104,12 +104,12 @@ work left open. Sizes: S (a day or two), M (about a week), L (weeks).
104104 |---|---|---|105105 | Open 012's XLSX output in real Excel and LibreOffice (a round-trip corpus, run by hand before releases). In progress: a first sample is being checked in Excel | The new writer is checked only by excelize and 012's reader | S |106106 | Column and row formats travel with copy, cut and move; formulas reading blanks of a newly formatted column re-infer at once | Left open by step A | S |107-| Save as onto another existing file asks before replacing (local and `012 serve`) | Only the open file is checked today | S |107+| Save as onto another existing file asks before replacing (local and `012 serve`). Done: Save as, `:w name` and `:wq name` ask on the context line ([files.md](docs/files.md)) | Replacing a file by mistake loses it | S |108108 | XLSX sheet names with characters Excel forbids: rewrite formulas that name them, not just the sheet | Formulas keep the old name today | S |109109 | Replace current sheet keeps its charts; Insert new sheet(s) goes after the current tab | Import location details | S |110110 | Precedent tracing explains when every precedent is on a hidden sheet | Says "no formula" today | S |111-| Settings > JEV API key checks the key with a test call | Left open by the config work | S |112-| `012 serve`: open a file from the ssh command line (`ssh -t host file.012` as a request, not exec), and save unsaved work to a recovery file on shutdown or idle timeout | Sessions start empty; unsaved work is lost today | M |111+| Settings > JEV API key checks the key with a test call. Done: one fixed question, "Key saved and checked" or why the check failed ([jev.md](docs/jev.md#setup)) | A mistyped key otherwise shows only as `#ERROR!` in cells | S |112+| `012 serve`: open a file from the ssh command line, and save unsaved work to a recovery file on shutdown or idle timeout. Done: `ssh -t host file.012` (a one-word exec request taken as a file name inside the served directory, never run) and `.012-recovery/`, offered back on the next open ([ssh.md](docs/ssh.md#unsaved-work)) | Sessions reach a file in one step, and stopping the server or idling out keeps work | M |113113 114114 ### 2. Spreadsheet features Sheets users reach for115115 @@ -397,8 +397,11 @@ and a pie image supersamples only pixels on a slice edge or the rim.
397397 ## Serving over SSH398398 399399 `internal/serve` wraps `charm.land/ssh` (wish's server) with only a400-session channel, public-key auth against authorized_keys and a shell401-request allowed. Each session builds a `ui.Model` of its own and runs it400+session channel, public-key auth against authorized_keys, and a shell401+request or, with a PTY, a one-word exec request allowed. The exec402+request is never run: its word is a file name, resolved through the403+served directory's `confine.Root` before the session starts and handed404+to `Model.OpenOnStart`. Each session builds a `ui.Model` of its own and runs it402405 in its own `tea.Program` over the session (wish's emulated PTY), with403406 the client's environment and window size; `Model.Serve` gives it the404407 client's environment for terminal detection and a `confine.Root` for@@ -408,6 +411,15 @@ registries), the process-wide telemetry and, with JEV on, the HTTP
408411 client; each gets its own `jev.Cache`, whose queue belongs to that409412 session's program. See [ssh.md](ssh.md).410413 414+**Unsaved work.** `Server.Shutdown` closes a channel every session415+watches; a session quitting on it, or on its idle timeout, asks its416+model `Unsaved` and `Recover`, which writes the workbook to417+`.012-recovery/` (0700, files 0600, three kept per name) and returns418+the name to tell the client. The directory is hidden, so `confine`419+refuses it to names typed in a session; only `ui/recovery.go` reads and420+writes it, and a model opening a file (or starting on a new sheet)421+offers the newest recovery file kept for that name.422+411423 **File names.** The UI keeps names as typed and turns them into paths412424 only to read or write, through the model's `confine.Root`: the zero413425 root (the local app) uses them as they are, a served session's root@@ -49,7 +49,15 @@ below). Charts are an optional `charts` field that older builds
4949 ignore. Saves are atomic: 012 writes a temporary file and renames it. Saving over5050 the open file when something else wrote it since it was opened or last5151 saved (another program, or another session of [012 serve](ssh.md)) asks52-first: Enter overwrites, S saves under another name, Esc cancels.52+first: Enter overwrites, S saves under another name, Esc cancels. Save53+as (and `:w name`, `:wq name` with vim keys) onto another file that54+exists asks the same way: Enter replaces it, Esc cancels, and cancelling55+`:wq`'s question cancels its quit too.56+57+Under [012 serve](ssh.md), a session that idles out or is ended by the58+server stopping keeps its unsaved changes in `.012-recovery/` in the59+served directory, and the next session opening that file offers them60+back: see [ssh.md](ssh.md#unsaved-work).5361 5462 Macros are an optional `macros` list, one macro per line with its5563 Starlark script as a string, and `macroOrigin`, the computer they were@@ -9,9 +9,17 @@ sessions are two separate spreadsheets (see
99 1010 ```sh1111 012 serve ~/sheets # serve ~/sheets on 127.0.0.1:231212-ssh -p 2312 localhost # from this machine12+ssh -p 2312 localhost # from this machine, on a new sheet13+ssh -t -p 2312 localhost budget.012 # opening budget.012 from ~/sheets1314 ```1415 16+A file name after the host opens that file, as `012 budget.012` does17+locally: a `.012` sheet, a new sheet to be saved under that name when18+there's no such file, or a file to import (`data.csv`). It must be one19+word (quote a name with spaces twice: `ssh -t host "'my book.012'"`)20+and needs `-t`, since ssh only asks for a terminal on its own when21+there's no command.22+1523 ```1624 012 serve [--listen addr] [--authorized-keys file] [--host-key file]1725 [--idle-timeout 30m] [--max-sessions 8] [--log file] [--otlp url] [dir]@@ -23,7 +31,7 @@ ssh -p 2312 localhost # from this machine
2331 | `--listen` | `127.0.0.1:2312` | Address to listen on. Loopback only unless you change it |2432 | `--authorized-keys` | `~/.ssh/authorized_keys` | Public keys allowed in, in OpenSSH's format |2533 | `--host-key` | `<config dir>/012/ssh_host_ed25519_key` | The server's key, generated on first run |26-| `--idle-timeout` | `30m` | End a session after this long without input; `0` never does |34+| `--idle-timeout` | `30m` | End a session after this long without input, keeping its unsaved changes; `0` never does |2735 | `--max-sessions` | `8` | Sessions at once; more are turned away with a message |2836 | `--log`, `--otlp` | off | Telemetry, as for the app ([observability.md](observability.md)) |2937 @@ -53,8 +61,8 @@ connect.
5361 a private network such as Tailscale with `--listen 100.x.y.z:2312`.5462 Listening beyond loopback prints a warning.5563 56-Stop the server with Ctrl+C. Running sessions end with it, and unsaved57-changes in them are lost.64+Stop the server with Ctrl+C. Running sessions end with it, keeping65+their unsaved changes (see [Unsaved work](#unsaved-work)).5866 5967 ## Security model6068 @@ -73,18 +81,28 @@ the authorized keys. Everything else is closed.
7381 - **Host key.** An ed25519 key generated on first run, written `0600`7482 in a `0700` directory. 012 refuses to start if the key is readable by7583 others.76-- **What a session can do.** Run 012, nothing else. Exec requests77- (`ssh host cat /etc/passwd`), subsystems (`sftp`, `scp`), local and78- remote port forwarding and X11 are refused; agent forwarding requests79- are accepted by the SSH library but never used. A session without a84+- **What a session can do.** Run 012, nothing else; no command is ever85+ executed. A shell request starts 012 on a new sheet. An exec request86+ is accepted only with a terminal (`ssh -t`) and only when it is one87+ word, which is taken as a file name and never run: it resolves inside88+ the served directory like a name typed in File > Open, and anything89+ that doesn't (see Files below), or holds control characters, ends the90+ session with the reason before 012 starts. Other exec requests91+ (`ssh host cat /etc/passwd`, `ssh -t host sh -c id`, any exec without92+ a terminal) are refused, as are subsystems (`sftp`, `scp`), local and93+ remote port forwarding and X11; agent forwarding requests are94+ accepted by the SSH library but never used. A shell without a8095 terminal is told to use `ssh -t` and closed.81-- **Files.** Names typed in File > Open, Save as, Import and Download82- resolve inside the served directory. Refused, with "outside the served83- directory": absolute paths, `..` that climbs out, hidden files and84- directories (`.env`, `.git`, `.ssh`), and symbolic links that lead85- outside or nowhere. Links that stay inside work. Error messages name86- files relative to the served directory, not where it is on the87- server. Files are written with the server's user and permissions.96+- **Files.** Names typed in File > Open, Save as, Import and Download,97+ and a file name given on the ssh command line, resolve inside the98+ served directory. Refused, with "outside the served directory":99+ absolute paths, `..` that climbs out, hidden files and directories100+ (`.env`, `.git`, `.ssh`, `.012-recovery`), and symbolic links that101+ lead outside or nowhere. Links that stay inside work. Error messages102+ name files relative to the served directory, not where it is on the103+ server. Files are written with the server's user and permissions;104+ recovery files are the one thing 012 writes that no one named, always105+ in `.012-recovery/` (see [Unsaved work](#unsaved-work)).88106 - **JEV.** On only when the server process's own environment has89107 `TYPESAFE_API_KEY`; sessions never read `.env` files. Every session90108 has its own answer cache, and all of them spend the server's key.@@ -95,8 +113,8 @@ the authorized keys. Everything else is closed.
95113 96114 | Event | When | Attributes |97115 |---|---|---|98-| `ssh.session` | a session starts | `user`, `key`, `remote`, `term`, `width`, `height` |99-| `ssh.session_end` | it ends | `user`, `key`, `remote`, `duration`, `idle` (closed for idling) |116+| `ssh.session` | a session starts | `user`, `key`, `remote`, `term`, `width`, `height`, `file` (a file was named on the command line; the name isn't logged) |117+| `ssh.session_end` | it ends | `user`, `key`, `remote`, `duration`, `idle` (closed for idling), `stopped` (the server stopped), `recovered` (unsaved work was kept) |100118 | `ssh.rejected` | a key not in authorized_keys | `user`, `key`, `remote` |101119 | `ssh.full` | a session turned away at `--max-sessions` | `user`, `key`, `remote` |102120 | `ssh.failed` | a handshake or login fails | `remote`, `error` |@@ -125,6 +143,35 @@ server. A file's macros ask for trust once per session, since the
125143 session isn't the server's own computer, and scripts have no file,126144 network or clock access in any case.127145 146+## Unsaved work147+148+A session that ends without the user quitting, because it was idle for149+`--idle-timeout` or because the server is stopping (Ctrl+C, SIGTERM),150+keeps its unsaved changes: the whole workbook is written to151+`.012-recovery/<name>-<time>.012` in the served directory, and the152+client's terminal is told where before the connection closes:153+154+```155+012: closed after 30m0s without input156+012: unsaved changes kept in .012-recovery/budget-20260927-140203.012; open budget.012 again to restore them157+```158+159+The next session opening `budget.012` (from File > Open or160+`ssh -t host budget.012`) asks on the context line: Enter restores them161+as unsaved changes to `budget.012`, D deletes them, Esc leaves them for162+next time. Saving a restored workbook removes its recovery file, and163+saving over a file that changed on disk still asks first. A sheet never164+saved is kept as `(untitled)-<time>.012` and offered when a session165+starts on a new sheet.166+167+It stays small: nothing is kept for a workbook without changes or with168+no cells, and only the newest three files per name are kept. The169+directory is created `0700` and its files `0600`, and it's hidden, so no170+name typed in a session reaches it; only 012 itself reads and writes171+it. A session whose client just goes away (a dropped connection)172+keeps nothing, and neither does quitting, which asks about unsaved173+changes as the local app does.174+128175 ## Two sessions, one file129176 130177 Opening the same file in two sessions gives two copies. Saving over a@@ -135,9 +182,9 @@ check protects the local app from other programs writing the file.
135182 136183 ## Limits137184 138-- Sessions start on a new sheet; there's no way to open a file from the139- ssh command line, since commands are refused.140-- An idle session is closed without saving.185+- An idle session is closed without saving over its file; unsaved186+ changes go to a recovery file instead.187+- A dropped connection loses unsaved changes.141188 - Each session holds about 1.3 MiB of heap on a new sheet, plus about142189 300 B per cell of the sheets it opens; two sessions opening one file143190 hold two copies. With 50 sessions typing at once on loopback, frames@@ -82,6 +82,9 @@ type options struct {
8282 // program runs instead of 012, such as an ssh client reaching 0128383 // serve.8484 program string85+ // startsOn is what the first screen shows once it's up: READY86+ // unless set, such as a question asked at once.87+ startsOn string8588 }8689 8790 // start launches 012 in dir (a fresh temp dir if empty) with args.@@ -181,7 +184,10 @@ func startWith(t *testing.T, o options, args ...string) *session {
181184 s.enc.Close()182185 s.vt.Close()183186 })184- s.waitFor("READY")187+ if o.startsOn == "" {188+ o.startsOn = "READY"189+ }190+ s.waitFor(o.startsOn)185191 return s186192 }187193 @@ -0,0 +1,43 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "testing"7+)8+9+// Golden screens for files and settings: questions about replacing a10+// file, restoring kept changes over ssh, and checking an API key.11+var fileScreens = []screen{12+ {name: "save-as-replace", files: func(t *testing.T, dir string) {13+ if err := os.WriteFile(filepath.Join(dir, "budget.012"), []byte("A1 old\n"), 0o644); err != nil {14+ t.Fatal(err)15+ }16+ }, setup: func(s *session) {17+ budget(s)18+ s.keys("<ctrl+s>", "budget", "<enter>")19+ s.waitFor("budget.012 exists.")20+ }},21+ // Over ssh, a file whose changes a session kept when the server22+ // stopped.23+ {name: "recovery-offer", opts: options{startsOn: "were kept."}, ssh: []string{"budget.012"}, files: func(t *testing.T, dir string) {24+ os.Mkdir(filepath.Join(dir, ".012-recovery"), 0o700)25+ for path, a1 := range map[string]string{"budget.012": "Household budget 2025", ".012-recovery/budget-20260927-140203.012": "Household budget 2026"} {26+ book := `{"version": 2, "cells": {"A1": "` + a1 + `", "A3": "Rent", "B3": "1200"}}`27+ if err := os.WriteFile(filepath.Join(dir, path), []byte(book), 0o600); err != nil {28+ t.Fatal(err)29+ }30+ }31+ }, setup: func(s *session) {32+ s.waitFor("Unsaved changes to budget.012 were kept.")33+ }},34+ // Nothing listens on port 1: the key is stored, and its check fails.35+ {name: "api-key-check-failed", opts: options{env: []string{"TYPESAFE_BASE_URL=http://127.0.0.1:1"}}, setup: func(s *session) {36+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")37+ s.waitFor("Store the TypeSafe API key")38+ s.keys("<enter>", "test-key", "<enter>")39+ s.waitFor("Key saved, but the check failed")40+ }},41+}42+43+func init() { screens = append(screens, fileScreens...) }@@ -1,12 +1,6 @@
11 package e2e22 3-import (4- "os"5- "path/filepath"6- "testing"7-8- ghostty "go.mitchellh.com/libghostty"9-)3+import ghostty "go.mitchellh.com/libghostty"104 115 // The golden screens: every UI state recorded, how to reach it, and which126 // are recorded on a light terminal too. The fixtures they share are in@@ -101,22 +95,6 @@ var screens = []screen{
10195 s.keys("<ctrl+q>")10296 s.waitFor("unsaved changes")10397 }},104- {name: "save-as-replace", files: func(t *testing.T, dir string) {105- if err := os.WriteFile(filepath.Join(dir, "budget.012"), []byte("A1 old\n"), 0o644); err != nil {106- t.Fatal(err)107- }108- }, setup: func(s *session) {109- budget(s)110- s.keys("<ctrl+s>", "budget", "<enter>")111- s.waitFor("budget.012 exists.")112- }},113- // Nothing listens on port 1: the key is stored, and its check fails.114- {name: "api-key-check-failed", opts: options{env: []string{"TYPESAFE_BASE_URL=http://127.0.0.1:1"}}, setup: func(s *session) {115- s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")116- s.waitFor("Store the TypeSafe API key")117- s.keys("<enter>", "test-key", "<enter>")118- s.waitFor("Key saved, but the check failed")119- }},12098 {name: "prompt-width", setup: func(s *session) {12199 budget(s)122100 s.keys("<ctrl+home>", "<alt+o>", "c", "<enter>", "<right>", "<right>", "<right>")@@ -20,6 +20,10 @@ type screen struct {
2020 name string2121 opts options2222 files func(t *testing.T, dir string) // fills the working directory first23+ // ssh, when set, records the screen through 012 serve and a real24+ // ssh client, with these words on the ssh command line; files fills25+ // the served directory then.26+ ssh []string2327 setup func(s *session)2428 }2529 @@ -30,16 +34,7 @@ func TestScreens(t *testing.T) {
3034 }3135 for _, sc := range screens {3236 t.Run(sc.name, func(t *testing.T) {33- opts := sc.opts34- if opts.jev {35- srv, _ := fakeTypeSafe(t)36- opts.env = []string{"TYPESAFE_API_KEY=test-key", "TYPESAFE_BASE_URL=" + srv.URL}37- }38- if sc.files != nil {39- opts.dir = t.TempDir()40- sc.files(t, opts.dir)41- }42- s := startWith(t, opts)37+ s := startScreen(t, sc)4338 sc.setup(s)4439 got := s.stableHTML()4540 path := filepath.Join(dir, sc.name+".html")@@ -67,6 +62,31 @@ func TestScreens(t *testing.T) {
6762 }6863 }6964 65+// startScreen starts the session sc is recorded in: with a fake JEV66+// service, a directory of files, or through 012 serve and ssh, as sc67+// asks.68+func startScreen(t *testing.T, sc screen) *session {69+ opts := sc.opts70+ if opts.jev {71+ srv, _ := fakeTypeSafe(t)72+ opts.env = []string{"TYPESAFE_API_KEY=test-key", "TYPESAFE_BASE_URL=" + srv.URL}73+ }74+ if sc.files != nil {75+ opts.dir = t.TempDir()76+ sc.files(t, opts.dir)77+ }78+ var args []string79+ if sc.ssh != nil {80+ top, served := t.TempDir(), opts.dir81+ if served == "" {82+ served = t.TempDir()83+ }84+ opts.program, args = serveSSH(t, top, served)85+ opts.dir, args = top, append(args, sc.ssh...)86+ }87+ return startWith(t, opts, args...)88+}89+7090 // stableHTML waits until two captures in a row match, so a snapshot never7191 // catches a frame mid-render.7292 func (s *session) stableHTML() string {@@ -108,3 +108,24 @@ func TestServeOverSSH(t *testing.T) {
108108 s.keys("<ctrl+q>")109109 s.waitExit()110110 }111+112+// ssh -t host book.012 opens the file from the served directory; a name113+// outside it is refused before 012 starts.114+func TestServeOpensFileFromCommandLine(t *testing.T) {115+ top := t.TempDir()116+ served := filepath.Join(top, "served")117+ os.Mkdir(served, 0o755)118+ book := `{"version": 2, "cells": {"A1": "opened over ssh"}}`119+ if err := os.WriteFile(filepath.Join(served, "book.012"), []byte(book), 0o644); err != nil {120+ t.Fatal(err)121+ }122+ sshPath, args := serveSSH(t, top, served)123+124+ s := startWith(t, options{dir: top, program: sshPath}, append(args, "book.012")...)125+ s.waitForLine(gridRow1, " 1 opened over ssh")126+ s.keys("<ctrl+q>")127+ s.waitExit()128+129+ s = startWith(t, options{dir: top, program: sshPath, startsOn: "outside the served directory"}, append(args, "../id_ed25519")...)130+ s.waitExit()131+}@@ -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 20253+<span style="color:var(--vt-palette-3)">Unsaved</span> <span style="color:var(--vt-palette-3)">changes</span> <span style="color:var(--vt-palette-3)">to</span> <span style="color:var(--vt-palette-3)">budget.012</span> <span style="color:var(--vt-palette-3)">were</span> <span style="color:var(--vt-palette-3)">kept.</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> Restore <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> Discard <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> Later4+<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> budget 20256+<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="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>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>10+<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>11+<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+Kept Sep 27 14:02, when a session ended unsaved. Later asks again next time</pre>\ No newline at end of file@@ -0,0 +1,86 @@
1+package serve2+3+import (4+ "errors"5+ "fmt"6+ "io"7+ "path/filepath"8+ "strings"9+ "time"10+ "unicode"11+12+ "charm.land/ssh"13+14+ "github.com/FelineStateMachine/012/internal/confine"15+ "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui"17+)18+19+// Opening a file named on the ssh command line, and keeping a session's20+// unsaved work when it ends without the user quitting. See docs/ssh.md.21+22+// fileArg is the file an exec request names (ssh -t host file.012), ""23+// for a shell request. The request is never run: its one word is a file24+// name, which must resolve inside the served directory as names typed in25+// File > Open do. allowSession has refused anything but one word.26+func (s *Server) fileArg(sess ssh.Session) (string, error) {27+ if sess.RawCommand() == "" {28+ return "", nil29+ }30+ args := sess.Command()31+ if len(args) != 1 {32+ return "", errors.New("give one file name: ssh -t host name.012")33+ }34+ name := args[0]35+ if name == "" || strings.ContainsFunc(name, unicode.IsControl) {36+ return "", errors.New("that's not a file name")37+ }38+ names := []string{name}39+ if filepath.Ext(name) == "" {40+ names = append(names, name+sheet.FileExt) // as the session will open it41+ }42+ for _, n := range names {43+ if _, err := s.root.Resolve(n); err != nil {44+ return "", fmt.Errorf("can't open %s: %s", name, s.root.Scrub(err.Error()))45+ }46+ }47+ return name, nil48+}49+50+// ending is how a session ended, and where its unsaved work was kept.51+type ending struct {52+ idle, stopped bool // it idled out; the server stopped53+ kept string // the recovery file, relative to the served directory54+ keptFor string // the file name it was kept for, "" for a book never saved55+ err error // keeping it failed56+}57+58+// keep writes m's unsaved work to a recovery file when the session ended59+// by idling out or by the server stopping; when the user quits, the app60+// has already asked about unsaved changes.61+func (e *ending) keep(m *ui.Model) {62+ if !e.idle && !e.stopped || !m.Unsaved() {63+ return64+ }65+ e.keptFor = m.Filename()66+ e.kept, e.err = m.Recover(time.Now())67+}68+69+// tell says on the client's terminal why the session ended and where its70+// unsaved work went.71+func (e ending) tell(w io.Writer, idle time.Duration, root confine.Root) {72+ switch {73+ case e.stopped:74+ fmt.Fprint(w, "012: the server is stopping\r\n")75+ case e.idle:76+ fmt.Fprintf(w, "012: closed after %v without input\r\n", idle)77+ }78+ switch {79+ case e.err != nil:80+ fmt.Fprintf(w, "012: couldn't keep the unsaved changes: %s\r\n", root.Scrub(e.err.Error()))81+ case e.kept != "" && e.keptFor == "":82+ fmt.Fprintf(w, "012: unsaved changes kept in %s; connect again to restore them\r\n", e.kept)83+ case e.kept != "":84+ fmt.Fprintf(w, "012: unsaved changes kept in %s; open %s again to restore them\r\n", e.kept, e.keptFor)85+ }86+}@@ -0,0 +1,259 @@
1+package serve2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "io"8+ "os"9+ "path/filepath"10+ "strings"11+ "testing"12+ "time"13+14+ gossh "golang.org/x/crypto/ssh"15+16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// openExec starts a session with a terminal and an exec request for20+// command, as ssh -t host command does.21+func openExec(t testing.TB, c *gossh.Client, command string) (*term, error) {22+ t.Helper()23+ sess, err := c.NewSession()24+ if err != nil {25+ t.Fatal(err)26+ }27+ if err := sess.RequestPty("xterm-256color", 24, 90, gossh.TerminalModes{}); err != nil {28+ t.Fatal(err)29+ }30+ tm := &term{sess: sess, done: make(chan error, 1), wrote: make(chan struct{}, 1)}31+ tm.stdin, _ = sess.StdinPipe()32+ sess.Stdout = tm33+ sess.Stderr = tm34+ if err := sess.Start(command); err != nil {35+ sess.Close()36+ return nil, err37+ }38+ go func() { tm.done <- sess.Wait() }()39+ return tm, nil40+}41+42+// saveSheet writes a one-cell sheet at path.43+func saveSheet(t *testing.T, path, a1 string) {44+ t.Helper()45+ s := sheet.New()46+ s.Set(sheet.Addr{}, a1)47+ var b bytes.Buffer48+ if err := s.Write(&b); err != nil {49+ t.Fatal(err)50+ }51+ if err := os.WriteFile(path, b.Bytes(), 0o644); err != nil {52+ t.Fatal(err)53+ }54+}55+56+func a1(t *testing.T, path string) string {57+ t.Helper()58+ f, err := os.Open(path)59+ if err != nil {60+ return ""61+ }62+ defer f.Close()63+ s, err := sheet.Read(f)64+ if err != nil || s.Cell(sheet.Addr{}) == nil {65+ return ""66+ }67+ return s.Cell(sheet.Addr{}).Input68+}69+70+// ssh -t host book.012 opens book.012 from the served directory; a name71+// with no such file starts a new sheet to be saved under it.72+func TestOpenFromCommandLine(t *testing.T) {73+ key := newKey(t)74+ srv, addr, dir := testServer(t, nil, key)75+ saveSheet(t, filepath.Join(dir, "book.012"), "from disk")76+ c, err := dial(addr, key, srv.HostKey())77+ if err != nil {78+ t.Fatal(err)79+ }80+ defer c.Close()81+ for _, name := range []string{"book.012", "book"} {82+ tm, err := openExec(t, c, name)83+ if err != nil {84+ t.Fatalf("%s: %v", name, err)85+ }86+ tm.waitFor(t, "012 - book.012", "from disk")87+ io.WriteString(tm.stdin, "\x11")88+ tm.waitClosed(t)89+ }90+ tm, err := openExec(t, c, "fresh.012")91+ if err != nil {92+ t.Fatal(err)93+ }94+ tm.waitFor(t, "012 - fresh.012", "READY")95+ io.WriteString(tm.stdin, "new\r\x13")96+ deadline := time.Now().Add(5 * time.Second)97+ for a1(t, filepath.Join(dir, "fresh.012")) != "new" && time.Now().Before(deadline) {98+ time.Sleep(10 * time.Millisecond)99+ }100+ if got := a1(t, filepath.Join(dir, "fresh.012")); got != "new" {101+ t.Errorf("Ctrl+S saved %q", got)102+ }103+ io.WriteString(tm.stdin, "\x11")104+ tm.waitClosed(t)105+}106+107+// An exec request is only ever a file name inside the served directory:108+// commands, names outside it or hidden, and requests without a terminal109+// are refused, and nothing runs.110+func TestExecConfined(t *testing.T) {111+ key := newKey(t)112+ srv, addr, dir := testServer(t, nil, key)113+ outside := filepath.Join(filepath.Dir(dir), "outside.012")114+ saveSheet(t, outside, "secret")115+ os.Symlink(outside, filepath.Join(dir, "link.012"))116+ os.Mkdir(filepath.Join(dir, ".012-recovery"), 0o700)117+ saveSheet(t, filepath.Join(dir, ".012-recovery", "book-20260101-120000.012"), "kept")118+ saveSheet(t, filepath.Join(dir, "book.012"), "ok")119+ c, err := dial(addr, key, srv.HostKey())120+ if err != nil {121+ t.Fatal(err)122+ }123+ defer c.Close()124+125+ for _, name := range []string{"../outside.012", "../outside", outside, "link.012", "link", ".env", ".012-recovery/book-20260101-120000.012", "sub/../../outside.012"} {126+ tm, err := openExec(t, c, name)127+ if err != nil {128+ t.Fatalf("%s: request refused before the name was checked: %v", name, err)129+ }130+ tm.waitClosed(t)131+ out := tm.output()132+ if !strings.Contains(out, "outside the served directory") || strings.Contains(out, "secret") || strings.Contains(out, "kept") {133+ t.Errorf("%s: %q", name, out)134+ }135+ if strings.Contains(out, dir) {136+ t.Errorf("%s: the message names the server's directory: %q", name, out)137+ }138+ }139+140+ // Anything but one word is refused outright, as are exec requests141+ // without a terminal and names with control characters.142+ for _, cmd := range []string{"cat /etc/passwd", "book.012 book.012", "sh -c id", "", "'book\x1b[2J.012'"} {143+ tm, err := openExec(t, c, cmd)144+ if err == nil {145+ tm.waitClosed(t)146+ if out := tm.output(); strings.Contains(out, "READY") {147+ t.Errorf("%q started 012: %q", cmd, out)148+ }149+ }150+ }151+ sess, _ := c.NewSession()152+ if out, err := sess.CombinedOutput("book.012"); err == nil || len(out) > 0 {153+ t.Errorf("exec without a terminal: %q, %v", out, err)154+ }155+}156+157+// A session that idles out with unsaved changes keeps them in a158+// recovery file, 0600 in a 0700 directory, and says where; one without159+// changes keeps nothing.160+func TestRecoveryOnIdle(t *testing.T) {161+ key := newKey(t)162+ srv, addr, dir := testServer(t, func(o *Options) { o.IdleTimeout = 500 * time.Millisecond }, key)163+ c, err := dial(addr, key, srv.HostKey())164+ if err != nil {165+ t.Fatal(err)166+ }167+ defer c.Close()168+ tm := openTerm(t, c, 80, 24)169+ tm.waitFor(t, "READY")170+ tm.waitClosed(t)171+ if _, err := os.Stat(filepath.Join(dir, ".012-recovery")); !errors.Is(err, os.ErrNotExist) {172+ t.Fatalf("an unchanged session kept something: %v", err)173+ }174+175+ tm, err = openExec(t, c, "book.012")176+ if err != nil {177+ t.Fatal(err)178+ }179+ tm.waitFor(t, "READY")180+ io.WriteString(tm.stdin, "42\r")181+ tm.waitClosed(t)182+ out := tm.output()183+ if !strings.Contains(out, "closed after 500ms without input") ||184+ !strings.Contains(out, "unsaved changes kept in .012-recovery/book-") ||185+ !strings.Contains(out, "open book.012 again to restore them") {186+ t.Fatalf("no recovery note in %q", out[max(0, len(out)-300):])187+ }188+ rdir := filepath.Join(dir, ".012-recovery")189+ st, err := os.Stat(rdir)190+ if err != nil || st.Mode().Perm() != 0o700 {191+ t.Fatalf("recovery directory: %v %v", st, err)192+ }193+ files, _ := filepath.Glob(filepath.Join(rdir, "book-*.012"))194+ if len(files) != 1 {195+ t.Fatalf("recovery files %v", files)196+ }197+ if st, _ := os.Stat(files[0]); st.Mode().Perm() != 0o600 || a1(t, files[0]) != "42" {198+ t.Errorf("recovery file %v, A1 %q", st.Mode(), a1(t, files[0]))199+ }200+}201+202+// Stopping the server keeps each session's unsaved changes and says so;203+// the next session opening the file offers them back, and saving them204+// removes the recovery file.205+func TestRecoveryOnShutdown(t *testing.T) {206+ key := newKey(t)207+ srv, addr, dir := testServer(t, nil, key)208+ saveSheet(t, filepath.Join(dir, "book.012"), "saved")209+ c, err := dial(addr, key, srv.HostKey())210+ if err != nil {211+ t.Fatal(err)212+ }213+ tm, err := openExec(t, c, "book.012")214+ if err != nil {215+ t.Fatal(err)216+ }217+ tm.waitFor(t, "saved")218+ io.WriteString(tm.stdin, "unsaved\r")219+ tm.waitFor(t, "modified")220+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)221+ defer cancel()222+ if err := srv.Shutdown(ctx); err != nil {223+ t.Fatal(err)224+ }225+ tm.waitClosed(t)226+ if out := tm.output(); !strings.Contains(out, "the server is stopping") || !strings.Contains(out, "open book.012 again") {227+ t.Fatalf("no note: %q", out[max(0, len(out)-300):])228+ }229+ c.Close()230+231+ srv, addr, _ = testServer(t, func(o *Options) { o.Dir = dir }, key)232+ c, err = dial(addr, key, srv.HostKey())233+ if err != nil {234+ t.Fatal(err)235+ }236+ defer c.Close()237+ tm = openTerm(t, c, 90, 24)238+ tm.waitFor(t, "READY")239+ io.WriteString(tm.stdin, "\x0f") // Ctrl+O240+ tm.waitFor(t, "Open file")241+ io.WriteString(tm.stdin, "book\r")242+ tm.waitFor(t, "Unsaved changes to book.012 were kept.", "Restore")243+ io.WriteString(tm.stdin, "\r")244+ tm.waitFor(t, "unsaved", "Restored the kept changes")245+ if got := a1(t, filepath.Join(dir, "book.012")); got != "saved" {246+ t.Fatalf("restoring wrote the file: %q", got)247+ }248+ io.WriteString(tm.stdin, "\x13")249+ deadline := time.Now().Add(5 * time.Second)250+ for a1(t, filepath.Join(dir, "book.012")) != "unsaved" && time.Now().Before(deadline) {251+ time.Sleep(10 * time.Millisecond)252+ }253+ files, _ := filepath.Glob(filepath.Join(dir, ".012-recovery", "*"))254+ if a1(t, filepath.Join(dir, "book.012")) != "unsaved" || len(files) != 0 {255+ t.Errorf("after saving: A1 %q, recovery files %v", a1(t, filepath.Join(dir, "book.012")), files)256+ }257+ io.WriteString(tm.stdin, "\x11")258+ tm.waitClosed(t)259+}@@ -4,7 +4,8 @@
44 // It is built for one person reaching their own files from elsewhere,55 // not for sharing: public-key auth against an authorized_keys file only,66 // on the loopback address unless told otherwise, with no port7-// forwarding, no commands and no subsystems. See docs/ssh.md.7+// forwarding, no subsystems, and no commands: an exec request is only8+// ever a file name to open. See docs/ssh.md.89 package serve910 1011 import (@@ -7,6 +7,7 @@ import (
77 "io"88 "log/slog"99 "net"10+ "sync"1011 "sync/atomic"1112 "time"1213 @@ -35,6 +36,36 @@ type Server struct {
3536 hostPub gossh.PublicKey3637 slots chan struct{} // one per running session3738 active atomic.Int6439+40+ // stop is closed when the server shuts down, ending every session,41+ // which keeps its unsaved work first; running counts the sessions42+ // still doing so. mu orders sessions starting against stopping.43+ mu sync.Mutex44+ stopped bool45+ stop chan struct{}46+ running sync.WaitGroup47+}48+49+// halt tells every session to end; no new one starts after it.50+func (s *Server) halt() {51+ s.mu.Lock()52+ defer s.mu.Unlock()53+ if !s.stopped {54+ s.stopped = true55+ close(s.stop)56+ }57+}58+59+// begin counts a session as running, or reports false when the server60+// is stopping. A session that began calls s.running.Done when it ends.61+func (s *Server) begin() bool {62+ s.mu.Lock()63+ defer s.mu.Unlock()64+ if s.stopped {65+ return false66+ }67+ s.running.Add(1)68+ return true3869 }3970 4071 // Deps are what a Server takes from its surroundings rather than its@@ -72,7 +103,8 @@ func New(o Options, d Deps) (*Server, error) {
72103 if d.Log == nil {73104 d.Log = slog.New(slog.DiscardHandler)74105 }75- s := &Server{opts: o, root: root, log: d.Log, jev: d.JEV, hostPub: signer.PublicKey(), slots: make(chan struct{}, o.MaxSessions)}106+ s := &Server{opts: o, root: root, log: d.Log, jev: d.JEV, hostPub: signer.PublicKey(), slots: make(chan struct{}, o.MaxSessions),107+ stop: make(chan struct{})}76108 s.ssh = &ssh.Server{77109 Addr: o.Listen,78110 Handler: s.session,@@ -84,7 +116,7 @@ func New(o Options, d Deps) (*Server, error) {
84116 ChannelHandlers: map[string]ssh.ChannelHandler{"session": ssh.DefaultSessionHandler},85117 RequestHandlers: map[string]ssh.RequestHandler{},86118 SubsystemHandlers: map[string]ssh.SubsystemHandler{},87- SessionRequestCallback: allowShell,119+ SessionRequestCallback: allowSession,88120 ConnectionFailedCallback: func(c net.Conn, err error) {89121 s.event("ssh.failed", slog.String("remote", c.RemoteAddr().String()), slog.String("error", err.Error()))90122 },@@ -99,10 +131,20 @@ func New(o Options, d Deps) (*Server, error) {
99131 return s, nil100132 }101133 102-// allowShell lets a session start 012 and nothing else: exec requests103-// (ssh host command) and subsystems (sftp) are refused.104-func allowShell(sess ssh.Session, requestType string) bool {105- return requestType == "shell"134+// allowSession lets a session start 012 and nothing else. A shell135+// request starts it on a new sheet. An exec request is taken only as the136+// name of a file to open, with a terminal and in one word (ssh -t host137+// file.012); it is never run. Other exec requests (ssh host command) and138+// subsystems (sftp) are refused.139+func allowSession(sess ssh.Session, requestType string) bool {140+ switch requestType {141+ case "shell":142+ return true143+ case "exec":144+ _, _, pty := sess.Pty()145+ return pty && len(sess.Command()) == 1146+ }147+ return false106148 }107149 108150 // HostKey is the server's public key, for known_hosts.@@ -134,12 +176,48 @@ func (s *Server) Serve(l net.Listener) error {
134176 return err135177 }136178 137-// Shutdown stops listening and waits for sessions to end, until ctx is138-// done.139-func (s *Server) Shutdown(ctx context.Context) error { return s.ssh.Shutdown(ctx) }179+// Shutdown ends every session, which keeps its unsaved work in a180+// recovery file and tells the client, waits for them to end until ctx is181+// done, then closes the listener and the connections left. Those hold no182+// session, and a connection can hold nothing else.183+func (s *Server) Shutdown(ctx context.Context) error {184+ s.halt()185+ err := s.waitSessions(ctx)186+ if cerr := s.ssh.Close(); err == nil {187+ err = cerr188+ }189+ return err190+}191+192+// closeWait bounds how long Close waits for sessions to keep their193+// unsaved work.194+const closeWait = 5 * time.Second195+196+// Close stops the server and drops every connection at once. Sessions197+// still keep their unsaved work, for up to closeWait.198+func (s *Server) Close() error {199+ s.halt()200+ err := s.ssh.Close()201+ ctx, cancel := context.WithTimeout(context.Background(), closeWait)202+ defer cancel()203+ s.waitSessions(ctx)204+ return err205+}140206 141-// Close stops the server and drops every connection at once.142-func (s *Server) Close() error { return s.ssh.Close() }207+// waitSessions waits for running sessions to end, until ctx is done.208+func (s *Server) waitSessions(ctx context.Context) error {209+ done := make(chan struct{})210+ go func() {211+ s.running.Wait()212+ close(done)213+ }()214+ select {215+ case <-done:216+ return nil217+ case <-ctx.Done():218+ return ctx.Err()219+ }220+}143221 144222 // authorize accepts key if the authorized_keys file lists it, reading145223 // the file again so edits apply without a restart.@@ -176,6 +254,18 @@ func (s *Server) session(sess ssh.Session) {
176254 sess.Exit(1)177255 return178256 }257+ name, err := s.fileArg(sess)258+ if err != nil {259+ fmt.Fprintf(sess.Stderr(), "012: %v\r\n", err)260+ sess.Exit(1)261+ return262+ }263+ if !s.begin() {264+ fmt.Fprint(sess.Stderr(), "012: the server is stopping\r\n")265+ sess.Exit(1)266+ return267+ }268+ defer s.running.Done()179269 select {180270 case s.slots <- struct{}{}:181271 default:@@ -186,7 +276,8 @@ func (s *Server) session(sess ssh.Session) {
186276 }187277 n := s.active.Add(1)188278 telemetry.Set("ssh_sessions", n)189- s.event("ssh.session", append(who, slog.String("term", pty.Term), slog.Int("width", pty.Window.Width), slog.Int("height", pty.Window.Height))...)279+ s.event("ssh.session", append(who, slog.String("term", pty.Term), slog.Int("width", pty.Window.Width), slog.Int("height", pty.Window.Height),280+ slog.Bool("file", name != ""))...)190281 start := time.Now()191282 defer func() {192283 telemetry.Set("ssh_sessions", s.active.Add(-1))@@ -195,23 +286,24 @@ func (s *Server) session(sess ssh.Session) {
195286 196287 // The session's span holds what its program does, in one trace.197288 span := telemetry.Start("serve.session", slog.String("term", pty.Term))198- idle := s.run(sess, pty, winch, span.Parent())199- span.End(slog.Bool("idle", idle))289+ end := s.run(sess, pty, winch, name, span.Parent())290+ span.End(slog.Bool("idle", end.idle))200291 d := time.Since(start)201- s.event("ssh.session_end", append(who, slog.Duration("duration", d), slog.Bool("idle", idle))...)202- if idle {203- fmt.Fprintf(sess, "012: closed after %v without input\r\n", s.opts.IdleTimeout)204- }292+ s.event("ssh.session_end", append(who, slog.Duration("duration", d), slog.Bool("idle", end.idle),293+ slog.Bool("stopped", end.stopped), slog.Bool("recovered", end.kept != ""))...)294+ end.tell(sess, s.opts.IdleTimeout, s.root)205295 sess.Exit(0)206296 }207297 208-// run runs the program until it quits, the client goes away or the209-// session goes idle, which it reports.210-func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, parent telemetry.Parent) (idle bool) {298+// run runs the program, on the file name when there's one, until it299+// quits, the client goes away, the session goes idle or the server300+// stops, and keeps unsaved work for the last two.301+func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, name string, parent telemetry.Parent) ending {211302 m := ui.New(sheet.New(), "")212303 m.TraceUnder(parent)213304 env := append(sess.Environ(), "TERM="+pty.Term)214305 m.Serve(s.root, env)306+ m.OpenOnStart(name)215307 if s.jev != nil {216308 // Each session has its own cache: it queues that session's217309 // questions, and its answers recalculate that session's sheets.@@ -225,6 +317,13 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, par
225317 ctx, cancel := context.WithCancel(sess.Context())226318 defer cancel()227319 go resize(ctx, p, winch)320+ go func() {321+ select {322+ case <-s.stop:323+ p.Quit()324+ case <-ctx.Done():325+ }326+ }()228327 var idled atomic.Bool229328 if s.opts.IdleTimeout > 0 {230329 go func() {@@ -238,7 +337,19 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, par
238337 s.log.Error("session", "error", err.Error())239338 }240339 p.Kill()241- return idled.Load()340+ end := ending{idle: idled.Load(), stopped: s.stopping()}341+ end.keep(m)342+ return end343+}344+345+// stopping reports whether the server is shutting down.346+func (s *Server) stopping() bool {347+ select {348+ case <-s.stop:349+ return true350+ default:351+ return false352+ }242353 }243354 244355 // resize tells p about the client's window changes, and quits it when@@ -319,6 +319,7 @@ func (m *Model) handleSaved(msg savedMsg) tea.Cmd {
319319 }320320 m.filename, m.disk = msg.name, msg.stamp321321 m.changed = false322+ m.savedRecovered()322323 if quit {323324 return m.exit()324325 }@@ -332,6 +333,7 @@ func (m *Model) handleLoaded(msg loadedMsg) {
332333 }333334 m.reset(msg.sheet, msg.name)334335 m.disk = msg.stamp336+ m.offerRecovery(msg.name)335337 }336338 337339 // save writes to the current file, asking for a name the first time.@@ -80,6 +80,8 @@ type Model struct {
8080 quitAfterSave bool // "Save and quit" is waiting for the save to finish8181 disk stamp // the file on disk as last opened or saved here, to notice others' saves8282 root confine.Root // where file names resolve; confined when served over SSH83+ start *string // the file a served session opens first (OpenOnStart); nil once opened84+ recovered string // the recovery file restored into this book, removed once it's saved: recovery.go8385 8486 mode mode8587 note string // feedback on the last action, e.g. "Undid: clear B3"@@ -133,7 +135,7 @@ func (m *Model) TraceUnder(p telemetry.Parent) { m.spans.Enter(p) }
133135 // so the theme can adapt to light terminals.134136 func (m *Model) Init() tea.Cmd {135137 // jev.send starts any questions queued while loading the file.136- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd())138+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(m.spans.Parent()), m.startupCmd(), m.startOpenCmd())137139 }138140 139141 // Update implements tea.Model.@@ -184,6 +186,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184186 cmd = m.handleSaved(msg)185187 case loadedMsg:186188 m.handleLoaded(msg)189+ case restoredMsg:190+ m.restored(msg)187191 case filesMsg:188192 if m.prompt != nil {189193 m.prompt.files = msg@@ -0,0 +1,301 @@
1+package ui2+3+import (4+ "bytes"5+ "errors"6+ "fmt"7+ "io/fs"8+ "net/url"9+ "os"10+ "path/filepath"11+ "regexp"12+ "slices"13+ "strconv"14+ "strings"15+ "time"16+17+ tea "charm.land/bubbletea/v2"18+19+ "github.com/FelineStateMachine/012/internal/fileio"20+ "github.com/FelineStateMachine/012/internal/sheet"21+ "github.com/FelineStateMachine/012/internal/ui/transfer"22+)23+24+// Recovery files: when 012 serve stops, or a session idles out, with25+// unsaved changes, the workbook is kept in a hidden directory of the26+// served directory, and the next session opening that file offers it27+// back. The directory is hidden, so names typed in a session never reach28+// it (internal/confine); only this file reads and writes it. See29+// docs/ssh.md.30+31+// RecoveryDir is the directory, inside the served one, recovery files32+// are kept in.33+const RecoveryDir = ".012-recovery"34+35+const (36+ recoveryKeep = 3 // recovery files kept per file name; older ones are removed37+ recoveryUntitled = "(untitled)" // the key for a book never saved; "(" is escaped in file names38+ recoveryStamp = "20060102-150405"39+)40+41+// recoveryName matches a recovery file's name after its key and "-":42+// the time it was written, a count when two share a second, and .012.43+var recoveryName = regexp.MustCompile(`^(\d{8}-\d{6})(-\d+)?\.012$`)44+45+// OpenOnStart makes a served session start on name, as `ssh -t host46+// name` asks: a sheet to open (or a new one to save as name, when there's47+// no such file) or a file to import. Either way, or with name empty, the48+// session then offers unsaved changes kept for that name.49+func (m *Model) OpenOnStart(name string) {50+ m.start = &name51+}52+53+// startOpenCmd opens what OpenOnStart set, once, when the program starts.54+func (m *Model) startOpenCmd() tea.Cmd {55+ if m.start == nil {56+ return nil57+ }58+ name := *m.start59+ m.start = nil60+ if name == "" {61+ m.offerRecovery("")62+ return nil63+ }64+ if _, ok := fileio.KindOf(name); ok {65+ return m.startImport(name, fileio.Options{}, transfer.Book)66+ }67+ name = withExt(name)68+ p, ok := m.path("open", name)69+ if !ok {70+ return nil71+ }72+ if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) {73+ // A new sheet, saved under this name.74+ m.filename, m.disk = name, stamp{}75+ m.offerRecovery(name)76+ return nil77+ }78+ return loadCmd(name, p, m.spans.Parent())79+}80+81+// Unsaved reports whether the workbook has changes a recovery file82+// would keep: it was changed since it was last saved or opened, and83+// isn't empty.84+func (m *Model) Unsaved() bool {85+ if !m.changed {86+ return false87+ }88+ wb := m.sheet.Book()89+ if len(wb.Macros()) > 0 {90+ return true91+ }92+ for _, s := range slices.Concat(wb.Visible(), wb.HiddenSheets()) {93+ if _, ok := s.UsedRange(); ok {94+ return true95+ }96+ }97+ return false98+}99+100+// Filename is the open file's name as typed, "" for a book never saved.101+func (m *Model) Filename() string { return m.filename }102+103+// Recover writes the workbook to a new recovery file for its name,104+// removing the oldest beyond recoveryKeep, and returns the file's name105+// relative to the served directory. It's for 012 serve, when the program106+// has stopped; a model that isn't served has nowhere to put one.107+func (m *Model) Recover(now time.Time) (string, error) {108+ if !m.root.Confined() {109+ return "", errors.New("recovery files are only kept for served sessions")110+ }111+ var buf bytes.Buffer112+ if err := m.sheet.Write(&buf); err != nil {113+ return "", err114+ }115+ dir, err := recoveryDir(m.root.Dir(), true)116+ if err != nil {117+ return "", err118+ }119+ key := recoveryKey(m.filename)120+ base := key + "-" + now.Format(recoveryStamp)121+ for i := 1; i < 100; i++ {122+ name := base + sheet.FileExt123+ if i > 1 {124+ name = fmt.Sprintf("%s-%d%s", base, i, sheet.FileExt)125+ }126+ f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)127+ if errors.Is(err, fs.ErrExist) {128+ continue129+ }130+ if err != nil {131+ return "", err132+ }133+ _, err = f.Write(buf.Bytes())134+ if cerr := f.Close(); err == nil {135+ err = cerr136+ }137+ if err != nil {138+ os.Remove(f.Name())139+ return "", err140+ }141+ prune(dir, key)142+ return filepath.Join(RecoveryDir, name), nil143+ }144+ return "", errors.New("too many recovery files this second")145+}146+147+// recoveryDir is the recovery directory in root, made 0700 when create148+// is set. It must be a directory of its own, not a link elsewhere.149+func recoveryDir(root string, create bool) (string, error) {150+ dir := filepath.Join(root, RecoveryDir)151+ if create {152+ if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, fs.ErrExist) {153+ return "", err154+ }155+ }156+ st, err := os.Lstat(dir)157+ if err != nil {158+ return "", err159+ }160+ if !st.IsDir() {161+ return "", fmt.Errorf("%s isn't a directory", RecoveryDir)162+ }163+ return dir, nil164+}165+166+// recoveryKey is the start of the recovery files' names for a file name167+// as typed: the name without its extension, escaped so any path is one168+// file name, or recoveryUntitled for a book never saved.169+func recoveryKey(name string) string {170+ if name == "" {171+ return recoveryUntitled172+ }173+ name = filepath.ToSlash(filepath.Clean(name))174+ return url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))175+}176+177+// recoveryFile is a recovery file found for a name.178+type recoveryFile struct {179+ path string180+ when time.Time181+ n int // which of the files written in that second182+}183+184+// recoveries lists the recovery files kept for key in dir, oldest first.185+func recoveries(dir, key string) []recoveryFile {186+ entries, _ := os.ReadDir(dir)187+ var out []recoveryFile188+ for _, e := range entries {189+ rest, ok := strings.CutPrefix(e.Name(), key+"-")190+ if !ok || !e.Type().IsRegular() {191+ continue192+ }193+ mm := recoveryName.FindStringSubmatch(rest)194+ if mm == nil {195+ continue196+ }197+ when, err := time.ParseInLocation(recoveryStamp, mm[1], time.Local)198+ if err != nil {199+ continue200+ }201+ n, _ := strconv.Atoi(strings.TrimPrefix(mm[2], "-")) // 0 for the first in its second202+ out = append(out, recoveryFile{path: filepath.Join(dir, e.Name()), when: when, n: n})203+ }204+ slices.SortFunc(out, func(a, b recoveryFile) int {205+ if c := a.when.Compare(b.when); c != 0 {206+ return c207+ }208+ return a.n - b.n209+ })210+ return out211+}212+213+// prune removes the oldest recovery files for key beyond recoveryKeep.214+func prune(dir, key string) {215+ files := recoveries(dir, key)216+ for _, f := range files[:max(0, len(files)-recoveryKeep)] {217+ os.Remove(f.path)218+ }219+}220+221+// offerRecovery asks, on the context line, whether to restore the222+// newest recovery file kept for name, when a served session opens it.223+func (m *Model) offerRecovery(name string) {224+ if !m.root.Confined() {225+ return226+ }227+ dir, err := recoveryDir(m.root.Dir(), false)228+ if err != nil {229+ return230+ }231+ files := recoveries(dir, recoveryKey(name))232+ if len(files) == 0 {233+ return234+ }235+ f := files[len(files)-1]236+ what := "an untitled sheet"237+ if name != "" {238+ what = filepath.Base(name)239+ }240+ m.openOverlay(&choiceBar{241+ m: m,242+ msg: "Unsaved changes to " + what + " were kept.",243+ warn: true,244+ desc: "Kept " + f.when.Format("Jan 2 15:04") + ", when a session ended unsaved. Later asks again next time",245+ choices: []choice{246+ {key: "enter", label: "Restore", run: func(m *Model) tea.Cmd { return restoreCmd(name, f.path) }},247+ {key: "d", label: "Discard", run: func(m *Model) tea.Cmd {248+ if err := os.Remove(f.path); err != nil {249+ m.fail("Couldn't delete the kept changes: " + err.Error())250+ return nil251+ }252+ m.note = "Deleted the kept changes to " + what253+ return nil254+ }},255+ {key: "esc", label: "Later", run: func(*Model) tea.Cmd { return nil }},256+ },257+ })258+}259+260+// restoredMsg carries a recovery file read back for name.261+type restoredMsg struct {262+ name, file string263+ sheet *sheet.Sheet264+ err error265+}266+267+func restoreCmd(name, file string) tea.Cmd {268+ return func() tea.Msg {269+ f, err := os.Open(file)270+ if err != nil {271+ return restoredMsg{name: name, file: file, err: err}272+ }273+ defer f.Close()274+ s, err := sheet.Read(f)275+ return restoredMsg{name: name, file: file, sheet: s, err: err}276+ }277+}278+279+// restored puts the kept changes in place of the opened file, as unsaved280+// changes to it: saving still checks the file on disk hasn't changed281+// since it was opened. The recovery file goes once they're saved.282+func (m *Model) restored(msg restoredMsg) {283+ if msg.err != nil {284+ m.fail("Couldn't restore the kept changes: " + msg.err.Error())285+ return286+ }287+ disk := m.disk288+ m.reset(msg.sheet, msg.name)289+ m.disk, m.saved, m.changed = disk, -1, true290+ m.recovered = msg.file291+ m.note = "Restored the kept changes; save to keep them"292+}293+294+// savedRecovered removes the recovery file restored into this book, once295+// the book is saved.296+func (m *Model) savedRecovered() {297+ if m.recovered != "" {298+ os.Remove(m.recovered)299+ m.recovered = ""300+ }301+}@@ -0,0 +1,177 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/012/internal/confine"11+)12+13+// servedModel is a model served from a fresh directory, which it14+// returns.15+func servedModel(t *testing.T) (*Model, string) {16+ t.Helper()17+ dir := t.TempDir()18+ root, err := confine.New(dir)19+ if err != nil {20+ t.Fatal(err)21+ }22+ m := newModel()23+ m.Serve(root, []string{"TERM=xterm-256color"})24+ return m, root.Dir()25+}26+27+func TestRecoveryKey(t *testing.T) {28+ for name, want := range map[string]string{29+ "": "(untitled)",30+ "book.012": "book",31+ "book": "book",32+ "sub/a b.012": "sub%2Fa%20b",33+ "./x/../y.012": "y",34+ "(untitled).012": "%28untitled%29",35+ } {36+ if got := recoveryKey(name); got != want {37+ t.Errorf("recoveryKey(%q) = %q, want %q", name, got, want)38+ }39+ }40+}41+42+// Only changed books that hold something are kept, only when served,43+// and at most recoveryKeep per name, newest kept.44+func TestRecoverKeepsFew(t *testing.T) {45+ local := newModel()46+ press(t, local, "1", "<enter>")47+ if _, err := local.Recover(time.Now()); err == nil {48+ t.Error("a local model kept a recovery file")49+ }50+51+ m, dir := servedModel(t)52+ if m.Unsaved() {53+ t.Error("a new sheet is unsaved")54+ }55+ press(t, m, "x", "<enter>", "<up>", "<delete>")56+ if !m.changed || m.Unsaved() {57+ t.Errorf("an emptied sheet: changed %v, unsaved %v", m.changed, m.Unsaved())58+ }59+ press(t, m, "42", "<enter>")60+ m.filename = "book.012"61+ at := time.Date(2026, 9, 27, 14, 2, 3, 0, time.Local)62+ var names []string63+ for i := range 5 {64+ name, err := m.Recover(at.Add(time.Duration(i/2) * time.Second))65+ if err != nil {66+ t.Fatal(err)67+ }68+ names = append(names, name)69+ }70+ if names[0] != filepath.Join(RecoveryDir, "book-20260927-140203.012") || names[1] != filepath.Join(RecoveryDir, "book-20260927-140203-2.012") {71+ t.Errorf("names %v", names)72+ }73+ // Another name's files, and one that only starts like this one's,74+ // are left alone.75+ os.WriteFile(filepath.Join(dir, RecoveryDir, "book-20260101-000000-20260101-000000.012"), nil, 0o600)76+ os.WriteFile(filepath.Join(dir, RecoveryDir, "other-20200101-000000.012"), nil, 0o600)77+ if _, err := m.Recover(at.Add(time.Hour)); err != nil {78+ t.Fatal(err)79+ }80+ files := recoveries(filepath.Join(dir, RecoveryDir), "book")81+ var got []string82+ for _, f := range files {83+ got = append(got, filepath.Base(f.path))84+ }85+ want := []string{"book-20260927-140204-2.012", "book-20260927-140205.012", "book-20260927-150203.012"}86+ if strings.Join(got, " ") != strings.Join(want, " ") {87+ t.Errorf("kept %v, want %v", got, want)88+ }89+ for _, other := range []string{"book-20260101-000000-20260101-000000.012", "other-20200101-000000.012"} {90+ if _, err := os.Stat(filepath.Join(dir, RecoveryDir, other)); err != nil {91+ t.Errorf("%s removed", other)92+ }93+ }94+}95+96+// A served session starting on a new sheet offers an untitled book's97+// kept changes: Esc keeps them for later, D deletes them.98+func TestRecoveryOfferUntitled(t *testing.T) {99+ m, dir := servedModel(t)100+ press(t, m, "kept", "<enter>")101+ file, err := m.Recover(time.Now())102+ if err != nil {103+ t.Fatal(err)104+ }105+ for _, key := range []string{"<esc>", "d"} {106+ m, _ := servedModel(t)107+ m.root, _ = confine.New(dir)108+ m.OpenOnStart("")109+ run(m, m.Init())110+ if !strings.Contains(line(m, contextLine), "Unsaved changes to an untitled sheet were kept.") {111+ t.Fatalf("no offer: %q", line(m, contextLine))112+ }113+ press(t, m, key)114+ _, err := os.Stat(filepath.Join(dir, file))115+ if key == "<esc>" && err != nil || key == "d" && err == nil {116+ t.Errorf("%s: recovery file there: %v", key, err == nil)117+ }118+ if input(m, "A1") != "" {119+ t.Errorf("%s restored it", key)120+ }121+ }122+}123+124+// Opening a file with kept changes offers them; restoring makes them125+// unsaved changes to that file, and saving them removes the recovery126+// file. A local model never offers.127+func TestRecoveryRestore(t *testing.T) {128+ m, dir := servedModel(t)129+ writeSheet(t, filepath.Join(dir, "book.012"), "on disk")130+ m.OpenOnStart("book")131+ run(m, m.Init())132+ press(t, m, "mine", "<enter>")133+ file, err := m.Recover(time.Now())134+ if err != nil {135+ t.Fatal(err)136+ }137+138+ m, _ = servedModel(t)139+ m.root, _ = confine.New(dir)140+ m.OpenOnStart("book.012")141+ run(m, m.Init())142+ if input(m, "A1") != "on disk" || !strings.Contains(line(m, contextLine), "Unsaved changes to book.012 were kept.") {143+ t.Fatalf("A1 %q, context %q", input(m, "A1"), line(m, contextLine))144+ }145+ press(t, m, "<enter>")146+ if input(m, "A1") != "mine" || !m.changed || m.filename != "book.012" || m.recovered == "" {147+ t.Fatalf("restore: A1 %q, changed %v, file %q", input(m, "A1"), m.changed, m.filename)148+ }149+ if !strings.Contains(line(m, contextLine), "Restored the kept changes") {150+ t.Errorf("context %q", line(m, contextLine))151+ }152+ // Undoing to the restored state still differs from the file.153+ press(t, m, "x", "<enter>", "<ctrl+z>")154+ if !m.changed {155+ t.Error("undo marked the restored book saved")156+ }157+ press(t, m, "<ctrl+s>")158+ if m.overlay != nil || m.changed {159+ t.Fatalf("save: overlay %T, changed %v, %q", m.overlay, m.changed, line(m, contextLine))160+ }161+ if got, _ := readSheet(filepath.Join(dir, "book.012")); a1Of(got) != "mine" {162+ t.Errorf("saved A1 %q", a1Of(got))163+ }164+ if _, err := os.Stat(filepath.Join(dir, file)); err == nil {165+ t.Error("saving kept the recovery file")166+ }167+168+ // Locally, nothing is offered.169+ os.MkdirAll(filepath.Join(dir, RecoveryDir), 0o700)170+ os.WriteFile(filepath.Join(dir, RecoveryDir, "book-20260101-000000.012"), nil, 0o600)171+ t.Chdir(dir)172+ local := newModel()173+ press(t, local, "<ctrl+o>", "book", "<enter>")174+ if local.overlay != nil || input(local, "A1") != "mine" {175+ t.Errorf("local open: overlay %T, A1 %q", local.overlay, input(local, "A1"))176+ }177+}