turbo-editors/turbo-corepublic Fork
⑂ 22712c7
Commits
⬇ Clone ▾
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

📦 Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks

k33g committed 2026-09-29T18:44:21+02:00 Browse files
22712c7 parent: 1e13893
added .memory/handoffs/2026-09-29-ssh-remote-editing.md +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# Handoff — 2026-09-29 — remote editing over ssh (`-ssh`)
2+
3+## State
4+
5+turbo-core has `fsys` (the one door to project files), `remote` (ssh ControlMaster + SFTP + remote shell/tool argv) and `app.UseRemote`. All six editors wire `-ssh`/`--ssh` in `main.go`. `go test ./...` green here; everything verified against a real sshd (Docker demo in `../demo-remote-machine`) including a password login driven in a pty. Uncommitted, on `main`, unreleased.
6+
7+## In flight
8+
9+Nothing half-written. The feature is complete for the agreed scope; what remains is release work.
10+
11+## Next steps
12+
13+1. Run the quality gate (`python3 ../.claude/skills/quality/scripts/quality_report.py --workspace .` — note the path; `qlty` must be installed, it was not in this sandbox).
14+2. Commit, set `release.env` to `v1.0.5`, run `./01-release.tag.sh`.
15+3. In each editor, remove the workspace and `go get rickub.com/turbo-editors/turbo-core@v1.0.5 && go mod tidy && GOWORK=off make check` — their two `TestTheTagScript*` tests go green only then.
16+4. Try it from macOS against a real server (never done): the control socket path shortening (`/tmp` when `os.TempDir()` > 60 bytes) is the macOS-specific part.
17+
18+## Open questions / blockers
19+
20+- **LSP over ssh** was deliberately left out (user's scope choice). If wanted later: run the server via `session.RunCommand(dir, server, false)` stdio and translate `file://` URIs — the paths are already remote-absolute, so maybe only the root needs care.
21+- **Agents over ssh** likewise off; ACP agents would need to run remotely too.
22+- **No reconnect** if the master dies mid-session (laptop sleep): saves fail with the SFTP error. Worth a ticket.
23+
24+## Watch out for
25+
26+- `remote` does **not** import `fsys`; its `FS` satisfies the interface structurally. Keep it that way (no cycle risk, but the diagram and packages reference say so).
27+- `pkg/sftp` test server: close the **server first** then the client in cleanup, or `client.Close` hangs waiting for the reader goroutine.
28+- A test that swaps `fsys.Use` must restore it (`t.Cleanup(func(){ fsys.Use(previous) })`) and must not run in parallel.
29+- `app/remote_test.go`'s fake remote echoes the directory; a long temp path wraps in the 80-column simulation screen — assert on a prefix.
30+- `go install golang.org/x/tools/cmd/goimports@latest` was used for import fixing (in `~/go/bin`), not a project tool.
31+
32+## Follow-up discussion (same day) — why LSP and agents are off over ssh, and the way to turn them on
33+
34+The user asked why a *local* language server / agent cannot work on remote files. Answer given, and the recommended next feature:
35+
36+- **Local LSP on remote files is wrong, not merely missing**: servers read the project from disk by path (pylsp/jedi → the remote virtualenv's site-packages; gopls → go.mod, module graph, module cache; rust-analyzer → `cargo metadata`, crates). `file:///home/bob/…` does not exist on the Mac → at best single-buffer completion, at worst "module not found" diagnostics everywhere.
37+- **Local agent on remote code is dangerous**: agents act with their own tools (docker agent never uses ACP `fs/*`), start in the *local* cwd, run shell commands locally, and could write local files believing they edit the project.
38+- **The right design for both is to run them on the remote machine over the shared connection** — both protocols are stdio: `ssh -S <sock> -T dest "cd DIR && exec pylsp"` / `… exec docker agent serve acp …`. Paths are already remote-absolute (fsys), so URIs match. To do for LSP: stop resolving the server executable on the local disk (`lsp/server.go` `os.Stat` of `Server.Dirs`), make `lsp/uri.go` stop calling local `filepath.Abs`/`EvalSymlinks` on remote paths, start the process via `Remote.RunCommand`-style argv; server must be installed remotely (install hint as today). For agents: same launch; route `readForAgent`/`writeForAgent` (`os.ReadFile`/`os.WriteFile` in `app/agents.go`) and `projectRoot()` (`os.Getwd`) through fsys; the model endpoint may be on the Mac's localhost → needs `ssh -R` reverse forward.
39+- **Recommended order: remote LSP first** (most useful, least work). The user has not yet said yes — ask at the start of the next session.
new file mode 100644
@@ -0,0 +1,39 @@
1+# Handoff — 2026-09-29 — remote editing over ssh (`-ssh`)
2+
3+## State
4+
5+turbo-core has `fsys` (the one door to project files), `remote` (ssh ControlMaster + SFTP + remote shell/tool argv) and `app.UseRemote`. All six editors wire `-ssh`/`--ssh` in `main.go`. `go test ./...` green here; everything verified against a real sshd (Docker demo in `../demo-remote-machine`) including a password login driven in a pty. Uncommitted, on `main`, unreleased.
6+
7+## In flight
8+
9+Nothing half-written. The feature is complete for the agreed scope; what remains is release work.
10+
11+## Next steps
12+
13+1. Run the quality gate (`python3 ../.claude/skills/quality/scripts/quality_report.py --workspace .` — note the path; `qlty` must be installed, it was not in this sandbox).
14+2. Commit, set `release.env` to `v1.0.5`, run `./01-release.tag.sh`.
15+3. In each editor, remove the workspace and `go get rickub.com/turbo-editors/turbo-core@v1.0.5 && go mod tidy && GOWORK=off make check` — their two `TestTheTagScript*` tests go green only then.
16+4. Try it from macOS against a real server (never done): the control socket path shortening (`/tmp` when `os.TempDir()` > 60 bytes) is the macOS-specific part.
17+
18+## Open questions / blockers
19+
20+- **LSP over ssh** was deliberately left out (user's scope choice). If wanted later: run the server via `session.RunCommand(dir, server, false)` stdio and translate `file://` URIs — the paths are already remote-absolute, so maybe only the root needs care.
21+- **Agents over ssh** likewise off; ACP agents would need to run remotely too.
22+- **No reconnect** if the master dies mid-session (laptop sleep): saves fail with the SFTP error. Worth a ticket.
23+
24+## Watch out for
25+
26+- `remote` does **not** import `fsys`; its `FS` satisfies the interface structurally. Keep it that way (no cycle risk, but the diagram and packages reference say so).
27+- `pkg/sftp` test server: close the **server first** then the client in cleanup, or `client.Close` hangs waiting for the reader goroutine.
28+- A test that swaps `fsys.Use` must restore it (`t.Cleanup(func(){ fsys.Use(previous) })`) and must not run in parallel.
29+- `app/remote_test.go`'s fake remote echoes the directory; a long temp path wraps in the 80-column simulation screen — assert on a prefix.
30+- `go install golang.org/x/tools/cmd/goimports@latest` was used for import fixing (in `~/go/bin`), not a project tool.
31+
32+## Follow-up discussion (same day) — why LSP and agents are off over ssh, and the way to turn them on
33+
34+The user asked why a *local* language server / agent cannot work on remote files. Answer given, and the recommended next feature:
35+
36+- **Local LSP on remote files is wrong, not merely missing**: servers read the project from disk by path (pylsp/jedi → the remote virtualenv's site-packages; gopls → go.mod, module graph, module cache; rust-analyzer → `cargo metadata`, crates). `file:///home/bob/…` does not exist on the Mac → at best single-buffer completion, at worst "module not found" diagnostics everywhere.
37+- **Local agent on remote code is dangerous**: agents act with their own tools (docker agent never uses ACP `fs/*`), start in the *local* cwd, run shell commands locally, and could write local files believing they edit the project.
38+- **The right design for both is to run them on the remote machine over the shared connection** — both protocols are stdio: `ssh -S <sock> -T dest "cd DIR && exec pylsp"` / `… exec docker agent serve acp …`. Paths are already remote-absolute (fsys), so URIs match. To do for LSP: stop resolving the server executable on the local disk (`lsp/server.go` `os.Stat` of `Server.Dirs`), make `lsp/uri.go` stop calling local `filepath.Abs`/`EvalSymlinks` on remote paths, start the process via `Remote.RunCommand`-style argv; server must be installed remotely (install hint as today). For agents: same launch; route `readForAgent`/`writeForAgent` (`os.ReadFile`/`os.WriteFile` in `app/agents.go`) and `projectRoot()` (`os.Getwd`) through fsys; the model endpoint may be on the Mac's localhost → needs `ssh -R` reverse forward.
39+- **Recommended order: remote LSP first** (most useful, least work). The user has not yet said yes — ask at the start of the next session.
modified .memory/history.md +12 -0
@@ -335,3 +335,15 @@
335335 - **Tests**: 20 new in `terminal`, 5 in `app` (both round trips, Edit ▸ Copy/Paste, bracketed paste from outside, highlight). `make check` green, `-race` green on `app`+`terminal`. Falsified: removing the terminal branch from `App.Copy` turned `TestEditCopyAndPasteReachTheTerminalInFront` red. Quality #39 PASS after the code, #40 PASS after the READMEs.
336336 - **Docs**: `terminal/README.md`, `app/README.md`, `docs/{en,fr}/reference/app.md` here; in **all six editors** (EN+FR): `how-to/use-a-terminal.md` (new section), `reference/terminal.md` (finished-window paragraph, keys table, mouse table, new "Copying and pasting" table, mode 2004 row, "not implemented" list, colours table), `reference/keyboard.md` (terminal and mouse rows), `explanation/terminal-windows.md` (new section). Applied by one anchored script; every anchor matched once in every editor.
337337 - **Not done**: not committed, not tagged (v1.0.4 still pending along with #30 and #31 from the previous session); not tried in a real terminal by hand; no ticket was opened for it. Noted for the user: the AsciiDoc work of the previous session was still uncommitted when this started, and this sits on top of it.
338+
339+## 2026-09-29 — Remote editing over ssh: `fsys`, `remote`, `app.UseRemote` (ticket #33)
340+
341+- **Asked** (umbrella session, `/methodical-dev`): `turbo-python -ssh user@machine` / `--ssh`, to edit files on a remote machine; then extended by the user to all six editors; plus a Docker Compose "remote machine" in `turbo-editors/demo-remote-machine` testable with a password. User chose: the system `ssh` with ControlMaster (after an explanation of it vs `x/crypto/ssh`), SFTP via `github.com/pkg/sftp` (vs a hand-written SFTP client or shell commands), scope files + tree + terminals + tools, agents disabled, LSP off, `user@machine[:path]`, stay on `main`, no stops between steps ("va au bout du bout").
342+- **Changes**: new `fsys/` (`FS`, `Local`, `Use`, `Current`, `IsLocal`, `Abs`, package funcs) and `fsys/fsystest/` (`Memory`); new `remote/` (`target.go`, `session.go`, `files.go`); `app/remote.go` (`Remote`, `UseRemote`, `Remote()`, `refuseAgentsOverSSH`, `connectionStatus`); `app/terminals.go` (`shellOptions`, `commandOptions`), `app/toolchain.go` (`startTool`), `app/agents.go` + `app/menus.go` (agents refused remotely), `app/app.go` (`remote` field, status); `tools/run.go` (`StartProgram`, shared `start`) + `programAttributes` per platform; `os.*` → `fsys.*` in buffer, projectfile, settings, snippets (project file only), tools, filetree and ten `app` files. `go.mod`: `github.com/pkg/sftp v1.13.11` (x/sys → v0.47.0, x/term, x/text bumped by MVS; `x/crypto`, `kr/fs` indirect).
343+- **Bug found and fixed on the way**: `Session.Close` would hang if the SFTP channel's process started but never spoke SFTP (no client to close its stdin) — now killed; test `TestAChannelThatNeverSpeaksSFTPIsReportedAndCloseDoesNotHang`.
344+- **Tests**: `fsys` (7 + example), `fsystest` (4), `remote` (21: target parsing incl. option-injection refusal, FS against pkg/sftp's real server over pipes, Connect/Close end to end with the test binary as a fake ssh, command lines, quoting, `Open`), `remote/integration_test.go` (real sshd, skipped without `TURBO_CORE_SSH_KEY` — passed against the demo container), routing tests `fsys_test.go` in buffer (4), projectfile, settings, tools, filetree, snippets, app (3), `app/remote_test.go` (7), `tools/program_test.go` (3). `go test ./...` green.
345+- **Verified end to end**: `turbo-python -ssh` in a pty with password auth against the demo container (see summary).
346+- **Quality**: gate **not run** — `qlty` absent from this sandbox; the skill forbids installing it by hand. `gofmt -l` and `go vet ./...` clean.
347+- **Docs** (EN+FR): new `reference/fsys.md`, `reference/remote.md`, `how-to/add-remote-editing.md`, `explanation/remote-editing.md`; updated `reference/packages.md`, `reference/app.md`, `explanation/architecture.md`, `how-to/run-the-tests.md`, both indexes, root `README.md`; package READMEs `fsys/`, `remote/` new, app/tools/buffer/projectfile/filetree/settings/snippets updated; `docs/diagrams/packages.drawio` regenerated from `go list` (it had drifted before this session).
348+- **Not done**: nothing committed or tagged; v1.0.5 not released.
349+- **Later, same session**: the user asked why LSP and agents are off over ssh; explained that a *local* server/agent on remote files is wrong (they read/act on their own disk by path), and that the fix is to run them *remotely* over the ControlMaster connection (both are stdio). Proposed remote LSP first; awaiting the user's go-ahead. Details and the files to change in the handoff `2026-09-29-ssh-remote-editing.md`. No code changed.
@@ -335,3 +335,15 @@
335 - **Tests**: 20 new in `terminal`, 5 in `app` (both round trips, Edit ▸ Copy/Paste, bracketed paste from outside, highlight). `make check` green, `-race` green on `app`+`terminal`. Falsified: removing the terminal branch from `App.Copy` turned `TestEditCopyAndPasteReachTheTerminalInFront` red. Quality #39 PASS after the code, #40 PASS after the READMEs.335 - **Tests**: 20 new in `terminal`, 5 in `app` (both round trips, Edit ▸ Copy/Paste, bracketed paste from outside, highlight). `make check` green, `-race` green on `app`+`terminal`. Falsified: removing the terminal branch from `App.Copy` turned `TestEditCopyAndPasteReachTheTerminalInFront` red. Quality #39 PASS after the code, #40 PASS after the READMEs.
336 - **Docs**: `terminal/README.md`, `app/README.md`, `docs/{en,fr}/reference/app.md` here; in **all six editors** (EN+FR): `how-to/use-a-terminal.md` (new section), `reference/terminal.md` (finished-window paragraph, keys table, mouse table, new "Copying and pasting" table, mode 2004 row, "not implemented" list, colours table), `reference/keyboard.md` (terminal and mouse rows), `explanation/terminal-windows.md` (new section). Applied by one anchored script; every anchor matched once in every editor.336 - **Docs**: `terminal/README.md`, `app/README.md`, `docs/{en,fr}/reference/app.md` here; in **all six editors** (EN+FR): `how-to/use-a-terminal.md` (new section), `reference/terminal.md` (finished-window paragraph, keys table, mouse table, new "Copying and pasting" table, mode 2004 row, "not implemented" list, colours table), `reference/keyboard.md` (terminal and mouse rows), `explanation/terminal-windows.md` (new section). Applied by one anchored script; every anchor matched once in every editor.
337 - **Not done**: not committed, not tagged (v1.0.4 still pending along with #30 and #31 from the previous session); not tried in a real terminal by hand; no ticket was opened for it. Noted for the user: the AsciiDoc work of the previous session was still uncommitted when this started, and this sits on top of it.337 - **Not done**: not committed, not tagged (v1.0.4 still pending along with #30 and #31 from the previous session); not tried in a real terminal by hand; no ticket was opened for it. Noted for the user: the AsciiDoc work of the previous session was still uncommitted when this started, and this sits on top of it.
338+
339+## 2026-09-29 — Remote editing over ssh: `fsys`, `remote`, `app.UseRemote` (ticket #33)
340+
341+- **Asked** (umbrella session, `/methodical-dev`): `turbo-python -ssh user@machine` / `--ssh`, to edit files on a remote machine; then extended by the user to all six editors; plus a Docker Compose "remote machine" in `turbo-editors/demo-remote-machine` testable with a password. User chose: the system `ssh` with ControlMaster (after an explanation of it vs `x/crypto/ssh`), SFTP via `github.com/pkg/sftp` (vs a hand-written SFTP client or shell commands), scope files + tree + terminals + tools, agents disabled, LSP off, `user@machine[:path]`, stay on `main`, no stops between steps ("va au bout du bout").
342+- **Changes**: new `fsys/` (`FS`, `Local`, `Use`, `Current`, `IsLocal`, `Abs`, package funcs) and `fsys/fsystest/` (`Memory`); new `remote/` (`target.go`, `session.go`, `files.go`); `app/remote.go` (`Remote`, `UseRemote`, `Remote()`, `refuseAgentsOverSSH`, `connectionStatus`); `app/terminals.go` (`shellOptions`, `commandOptions`), `app/toolchain.go` (`startTool`), `app/agents.go` + `app/menus.go` (agents refused remotely), `app/app.go` (`remote` field, status); `tools/run.go` (`StartProgram`, shared `start`) + `programAttributes` per platform; `os.*` → `fsys.*` in buffer, projectfile, settings, snippets (project file only), tools, filetree and ten `app` files. `go.mod`: `github.com/pkg/sftp v1.13.11` (x/sys → v0.47.0, x/term, x/text bumped by MVS; `x/crypto`, `kr/fs` indirect).
343+- **Bug found and fixed on the way**: `Session.Close` would hang if the SFTP channel's process started but never spoke SFTP (no client to close its stdin) — now killed; test `TestAChannelThatNeverSpeaksSFTPIsReportedAndCloseDoesNotHang`.
344+- **Tests**: `fsys` (7 + example), `fsystest` (4), `remote` (21: target parsing incl. option-injection refusal, FS against pkg/sftp's real server over pipes, Connect/Close end to end with the test binary as a fake ssh, command lines, quoting, `Open`), `remote/integration_test.go` (real sshd, skipped without `TURBO_CORE_SSH_KEY` — passed against the demo container), routing tests `fsys_test.go` in buffer (4), projectfile, settings, tools, filetree, snippets, app (3), `app/remote_test.go` (7), `tools/program_test.go` (3). `go test ./...` green.
345+- **Verified end to end**: `turbo-python -ssh` in a pty with password auth against the demo container (see summary).
346+- **Quality**: gate **not run** — `qlty` absent from this sandbox; the skill forbids installing it by hand. `gofmt -l` and `go vet ./...` clean.
347+- **Docs** (EN+FR): new `reference/fsys.md`, `reference/remote.md`, `how-to/add-remote-editing.md`, `explanation/remote-editing.md`; updated `reference/packages.md`, `reference/app.md`, `explanation/architecture.md`, `how-to/run-the-tests.md`, both indexes, root `README.md`; package READMEs `fsys/`, `remote/` new, app/tools/buffer/projectfile/filetree/settings/snippets updated; `docs/diagrams/packages.drawio` regenerated from `go list` (it had drifted before this session).
348+- **Not done**: nothing committed or tagged; v1.0.5 not released.
349+- **Later, same session**: the user asked why LSP and agents are off over ssh; explained that a *local* server/agent on remote files is wrong (they read/act on their own disk by path), and that the fix is to run them *remotely* over the ControlMaster connection (both are stdio). Proposed remote LSP first; awaiting the user's go-ahead. Details and the files to change in the handoff `2026-09-29-ssh-remote-editing.md`. No code changed.
modified .memory/summary.md +22 -4
@@ -14,10 +14,13 @@ Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted.
1414
1515 ## Architecture
1616
17-Nineteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.
17+Twenty-two packages (counting the root `main` that holds only `release_test.go`). Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.
1818
1919 ```
20-app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
20+app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version, fsys}
21+ remote → github.com/pkg/sftp ← execs ssh; its FS satisfies fsys.FS without importing fsys
22+ buffer │ projectfile │ filetree │ settings │ snippets │ tools → fsys (+ what is listed)
23+ fsys → stdlib only
2124 editor → {buffer, syntax, ui, theme}
2225 terminal → {ui, theme, tcell, golang.org/x/sys}
2326 filetree → {ui, theme, tcell}
@@ -34,8 +37,10 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t
3437 | Package | What it holds | Depends on |
3538 | --- | --- | --- |
3639 | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |
37-| `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** |
38-| `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** |
40+| `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | `fsys` |
41+| `projectfile` | Atomic writes of a project's own TOML files | `fsys` |
42+| `fsys` | The one door to the project's files (package-level `Use`, default `Local{}`); `fsys/fsystest.Memory` for tests | **stdlib only** |
43+| `remote` | `-ssh`: ControlMaster login via the `ssh` command, files over SFTP, argv of remote shells/tools | `github.com/pkg/sftp`, the `ssh` command |
3944 | `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command |
4045 | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |
4146 | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |
@@ -145,6 +150,8 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
145150
146151 - **Text is copied out of a terminal window and pasted into it (2026-09-21, v1.0.4).** `terminal/view_select.go`. The keys are the editor's *other* spelling of copy and paste — `Ctrl-Ins` / `Shift-Ins` — because `Ctrl-C` and `Ctrl-V` are the shell's; both are claimed even when they do nothing, since sent through either arrives as a plain `Insert`. The selection is two `Point`s of the **whole text** (history index + live rows), which is what keeps it on the same text while the shell writes below it — a line scrolling off the top keeps its index. Mouse selects by cell (drag clamped to the window's edge); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row and turn a mouse selection into whole lines (one rule, not two). Trailing blanks are trimmed only from a line selected to its right edge. A click drops the selection; output, scrolling and typing keep it. Highlight is `editor.selection`, no new theme key (the acp view does the same). Copy goes through `App.copyToClipboards` (renamed from `copyFromAgent`): editor clipboard + OSC 52 + `Copied N lines`. `Paste` turns `\n` into `\r` and, when the screen says **mode 2004** is on, wraps in `ESC[200~`…`ESC[201~` — the emulator now *remembers* 2004 (`Screen.BracketedPaste`), which is the one place its boundary moved; it still never pastes by itself. `App.Copy`/`App.Paste` route to a terminal in front; `handlePaste` hands a bracketed paste from the outer terminal to a terminal window whole, as it already did for an agent window. A finished terminal still selects and copies (the keys run before the `Exited` check) but `Paste` returns false. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`: only the event loop calls them, so no goroutine reads them. Accepted edge: when the scrollback is full (2000 lines), each trimmed line shifts a held selection up by one — dropped on the next click anyway. Rejected: Escape to drop the selection (vim needs Escape); `Shift-Home`/`End` (not needed yet); a `terminal.selection` theme key (nine TOML files for no visible gain). Tests: 20 in `terminal/view_select_test.go`, 5 in `app/terminal_clipboard_test.go` (round trip both ways, menu actions, bracketed paste, highlight).
147152
153+- **Remote editing over ssh drives the `ssh` command, shares one ControlMaster connection, and moves files over SFTP (2026-09-29, unreleased, ticket #33).** New `fsys` (package-level `Use(FS)`, same argument as `syntax.Register`) is now the only way `buffer`, `projectfile`, `settings`, `snippets` (project file only — the user's stays `os`), `tools`, `filetree` and `app` touch project files; themes, the user's snippets, `lsp`, `acp` and `configrepo` stay on `os` on purpose. New `remote`: `ParseTarget` (`[user@]host[:path]`, scp-style, no port — use `~/.ssh/config`; refuses a leading `-`), `Connect` runs `ssh -o ControlMaster=yes -o ControlPersist=60 -N -f` in the foreground **before tcell takes the screen** (password prompt there), then everything with `-S sock -o ControlMaster=no -o BatchMode=yes`: one `ssh -s … sftp` channel (`github.com/pkg/sftp` v1.13.11 — the only new dependency; it forced `golang.org/x/sys` v0.38→v0.47 by MVS), shells `ssh -t … "exec /bin/sh -c 'cd DIR && exec \"${SHELL:-/bin/sh}\" -l'"`, tools `-T`/`-t … "exec /bin/sh -c 'cd DIR && CMD'"`. Atomic save = temp + `posix-rename@openssh.com`. `app.UseRemote(app.Remote)` (3-method interface: `Name`, `ShellCommand`, `RunCommand`) routes F8 and tools through it (`tools.StartProgram` added for captured runs), shows `⇄ name` on the status bar, refuses agents (menu shows `(not available over ssh)`). No LSP over ssh: the command skips `StartLanguageServer`. Windows → `ErrUnsupported` (no ControlMaster). Rejected, with reasons in `docs/*/explanation/remote-editing.md`: `x/crypto/ssh` (ignores ~/.ssh/config, host-key prompt to reimplement), shell `cat`/`ls` (non-portable output, a process per stat), hand-written SFTP, sshfs, copying the binary over, threading an FS through every package. Chosen by the user: system ssh + ControlMaster, SFTP via pkg/sftp, scope = files+tree+terminals+tools, agents off, all six editors.
154+
148155 ## Build, test, run
149156
150157 ```bash
@@ -188,8 +195,19 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
188195
189196 - **v1.0.4 is ready to tag** (`release.env` says so): the shadow fix, AsciiDoc, and terminal copy/paste, above. `make check` green, `-race` green on `app` and `terminal`, quality gate #40 PASS, 0 smells. Not committed. Nothing in it changes an editor's code; re-pinning is cosmetic but every editor shows the defect until it does.
190197
198+## State as of 2026-09-29
199+
200+- **Remote editing (`-ssh`) is implemented but unreleased and uncommitted**, on `main`: `fsys/`, `fsys/fsystest/`, `remote/`, `app/remote.go`, `tools.StartProgram`, and the routing through `fsys` above. `go test ./...` green (root `release_test` included, since it runs inside the module); `gofmt`/`go vet` clean. **The editors need this released as v1.0.5** — until then they build only under a `go.work`, and their `release_test` fails (see their memories).
201+- **Verified for real**: `remote/integration_test.go` against the Docker sshd in `turbo-editors/demo-remote-machine` (`TURBO_CORE_SSH_KEY=… go test ./remote -run Real`), and `turbo-python -ssh` driven in a pty (pyte) with **password** login: prompt before the screen, remote file opened/edited/saved (checked on the machine), F9 tree, a popup tool printing `remote-machine`, F8 bash on the machine, status `⇄ … LSP: off`, clean exit, no master or socket left.
202+- **Quality gate NOT run**: `qlty` is not installed in this sandbox (and the skill's script is under `.claude/skills/quality/`, not `~/.claude/skills/`). Per the skill, not installed by hand. Run it before tagging v1.0.5.
203+- **Docs**: EN+FR `reference/fsys.md`, `reference/remote.md`, `how-to/add-remote-editing.md`, `explanation/remote-editing.md`; packages, app, architecture, index, run-the-tests updated; package READMEs for `fsys`, `remote` + updates to app, tools, buffer, projectfile, filetree, settings, snippets. `docs/diagrams/packages.drawio` **regenerated** from `go list` (22 packages, 4 external modules, 61 arrows) — the old one had drifted (no `configrepo`, no `fsys`).
204+
191205 ## Not yet established
192206
207+- **Remote editing has never run with a macOS client, a macOS server, or through a real `ProxyJump`.** Verified Linux→Linux (Docker) only. The control socket path is shortened to `/tmp` when `os.TempDir()` is longer than 60 bytes, which is the macOS case, but untested there.
208+- **LSP and agents over ssh: designed, not built.** Run them on the remote machine via `ssh -S … -T dest "cd DIR && exec <server|agent>"` (stdio); a local one on remote files is wrong. Files to change are listed in `handoffs/2026-09-29-ssh-remote-editing.md`. Awaiting the user's decision; remote LSP recommended first.
209+- **A dropped connection mid-session is unhandled beyond errors**: saves then fail with the SFTP error in a dialog; there is no reconnect.
210+
193211 - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.
194212 - **The `/` and `@` pickers have only been driven by tests.** Commands, hints, embedded and linked mentions are all asserted on the wire against the fake agent, and the popup on a simulation screen, but no real agent has announced a command to this client yet — `docker agent` was not available in the sandbox on 2026-09-15 — and the user's own `mini-me` agent (`mm -acp`, which Zed discovers commands from) has never been pointed at an agent window. Whether its `available_commands_update` arrives before or after `session/new` returns, and whether it embeds context, are both unknown.
195213 - **`release_test.go` fails in this sandbox for a reason that is not the code.** It copies the module with `cp -r`, and the sandbox's filesystem returns NUL bytes from `cp` for some recently written inodes (`acp/picker.go`, `app/agent_files.go` on 2026-09-15). Recreating the files at fresh inodes did **not** clear it this time. `cat`, `git`, `gofmt`, `go build` and `go test ./acp ./app` all read the right bytes.
@@ -14,10 +14,13 @@ Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted.
14 14
15 ## Architecture15 ## Architecture
16 16
17-Nineteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.17+Twenty-two packages (counting the root `main` that holds only `release_test.go`). Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one.
18 18
19 ```19 ```
20-app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}20+app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version, fsys}
21+ remote → github.com/pkg/sftp ← execs ssh; its FS satisfies fsys.FS without importing fsys
22+ buffer │ projectfile │ filetree │ settings │ snippets │ tools → fsys (+ what is listed)
23+ fsys → stdlib only
21 editor → {buffer, syntax, ui, theme}24 editor → {buffer, syntax, ui, theme}
22 terminal → {ui, theme, tcell, golang.org/x/sys}25 terminal → {ui, theme, tcell, golang.org/x/sys}
23 filetree → {ui, theme, tcell}26 filetree → {ui, theme, tcell}
@@ -34,8 +37,10 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t
34 | Package | What it holds | Depends on |37 | Package | What it holds | Depends on |
35 | --- | --- | --- |38 | --- | --- | --- |
36 | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |39 | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** |
37-| `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** |40+| `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | `fsys` |
38-| `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** |41+| `projectfile` | Atomic writes of a project's own TOML files | `fsys` |
42+| `fsys` | The one door to the project's files (package-level `Use`, default `Local{}`); `fsys/fsystest.Memory` for tests | **stdlib only** |
43+| `remote` | `-ssh`: ControlMaster login via the `ssh` command, files over SFTP, argv of remote shells/tools | `github.com/pkg/sftp`, the `ssh` command |
39 | `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command |44 | `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command |
40 | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |45 | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** |
41 | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |46 | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` |
@@ -145,6 +150,8 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve
145 150
146 - **Text is copied out of a terminal window and pasted into it (2026-09-21, v1.0.4).** `terminal/view_select.go`. The keys are the editor's *other* spelling of copy and paste — `Ctrl-Ins` / `Shift-Ins` — because `Ctrl-C` and `Ctrl-V` are the shell's; both are claimed even when they do nothing, since sent through either arrives as a plain `Insert`. The selection is two `Point`s of the **whole text** (history index + live rows), which is what keeps it on the same text while the shell writes below it — a line scrolling off the top keeps its index. Mouse selects by cell (drag clamped to the window's edge); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row and turn a mouse selection into whole lines (one rule, not two). Trailing blanks are trimmed only from a line selected to its right edge. A click drops the selection; output, scrolling and typing keep it. Highlight is `editor.selection`, no new theme key (the acp view does the same). Copy goes through `App.copyToClipboards` (renamed from `copyFromAgent`): editor clipboard + OSC 52 + `Copied N lines`. `Paste` turns `\n` into `\r` and, when the screen says **mode 2004** is on, wraps in `ESC[200~`…`ESC[201~` — the emulator now *remembers* 2004 (`Screen.BracketedPaste`), which is the one place its boundary moved; it still never pastes by itself. `App.Copy`/`App.Paste` route to a terminal in front; `handlePaste` hands a bracketed paste from the outer terminal to a terminal window whole, as it already did for an agent window. A finished terminal still selects and copies (the keys run before the `Exited` check) but `Paste` returns false. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`: only the event loop calls them, so no goroutine reads them. Accepted edge: when the scrollback is full (2000 lines), each trimmed line shifts a held selection up by one — dropped on the next click anyway. Rejected: Escape to drop the selection (vim needs Escape); `Shift-Home`/`End` (not needed yet); a `terminal.selection` theme key (nine TOML files for no visible gain). Tests: 20 in `terminal/view_select_test.go`, 5 in `app/terminal_clipboard_test.go` (round trip both ways, menu actions, bracketed paste, highlight).151 - **Text is copied out of a terminal window and pasted into it (2026-09-21, v1.0.4).** `terminal/view_select.go`. The keys are the editor's *other* spelling of copy and paste — `Ctrl-Ins` / `Shift-Ins` — because `Ctrl-C` and `Ctrl-V` are the shell's; both are claimed even when they do nothing, since sent through either arrives as a plain `Insert`. The selection is two `Point`s of the **whole text** (history index + live rows), which is what keeps it on the same text while the shell writes below it — a line scrolling off the top keeps its index. Mouse selects by cell (drag clamped to the window's edge); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row and turn a mouse selection into whole lines (one rule, not two). Trailing blanks are trimmed only from a line selected to its right edge. A click drops the selection; output, scrolling and typing keep it. Highlight is `editor.selection`, no new theme key (the acp view does the same). Copy goes through `App.copyToClipboards` (renamed from `copyFromAgent`): editor clipboard + OSC 52 + `Copied N lines`. `Paste` turns `\n` into `\r` and, when the screen says **mode 2004** is on, wraps in `ESC[200~`…`ESC[201~` — the emulator now *remembers* 2004 (`Screen.BracketedPaste`), which is the one place its boundary moved; it still never pastes by itself. `App.Copy`/`App.Paste` route to a terminal in front; `handlePaste` hands a bracketed paste from the outer terminal to a terminal window whole, as it already did for an agent window. A finished terminal still selects and copies (the keys run before the `Exited` check) but `Paste` returns false. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`: only the event loop calls them, so no goroutine reads them. Accepted edge: when the scrollback is full (2000 lines), each trimmed line shifts a held selection up by one — dropped on the next click anyway. Rejected: Escape to drop the selection (vim needs Escape); `Shift-Home`/`End` (not needed yet); a `terminal.selection` theme key (nine TOML files for no visible gain). Tests: 20 in `terminal/view_select_test.go`, 5 in `app/terminal_clipboard_test.go` (round trip both ways, menu actions, bracketed paste, highlight).
147 152
153+- **Remote editing over ssh drives the `ssh` command, shares one ControlMaster connection, and moves files over SFTP (2026-09-29, unreleased, ticket #33).** New `fsys` (package-level `Use(FS)`, same argument as `syntax.Register`) is now the only way `buffer`, `projectfile`, `settings`, `snippets` (project file only — the user's stays `os`), `tools`, `filetree` and `app` touch project files; themes, the user's snippets, `lsp`, `acp` and `configrepo` stay on `os` on purpose. New `remote`: `ParseTarget` (`[user@]host[:path]`, scp-style, no port — use `~/.ssh/config`; refuses a leading `-`), `Connect` runs `ssh -o ControlMaster=yes -o ControlPersist=60 -N -f` in the foreground **before tcell takes the screen** (password prompt there), then everything with `-S sock -o ControlMaster=no -o BatchMode=yes`: one `ssh -s … sftp` channel (`github.com/pkg/sftp` v1.13.11 — the only new dependency; it forced `golang.org/x/sys` v0.38→v0.47 by MVS), shells `ssh -t … "exec /bin/sh -c 'cd DIR && exec \"${SHELL:-/bin/sh}\" -l'"`, tools `-T`/`-t … "exec /bin/sh -c 'cd DIR && CMD'"`. Atomic save = temp + `posix-rename@openssh.com`. `app.UseRemote(app.Remote)` (3-method interface: `Name`, `ShellCommand`, `RunCommand`) routes F8 and tools through it (`tools.StartProgram` added for captured runs), shows `⇄ name` on the status bar, refuses agents (menu shows `(not available over ssh)`). No LSP over ssh: the command skips `StartLanguageServer`. Windows → `ErrUnsupported` (no ControlMaster). Rejected, with reasons in `docs/*/explanation/remote-editing.md`: `x/crypto/ssh` (ignores ~/.ssh/config, host-key prompt to reimplement), shell `cat`/`ls` (non-portable output, a process per stat), hand-written SFTP, sshfs, copying the binary over, threading an FS through every package. Chosen by the user: system ssh + ControlMaster, SFTP via pkg/sftp, scope = files+tree+terminals+tools, agents off, all six editors.
154+
148 ## Build, test, run155 ## Build, test, run
149 156
150 ```bash157 ```bash
@@ -188,8 +195,19 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
188 195
189 - **v1.0.4 is ready to tag** (`release.env` says so): the shadow fix, AsciiDoc, and terminal copy/paste, above. `make check` green, `-race` green on `app` and `terminal`, quality gate #40 PASS, 0 smells. Not committed. Nothing in it changes an editor's code; re-pinning is cosmetic but every editor shows the defect until it does.196 - **v1.0.4 is ready to tag** (`release.env` says so): the shadow fix, AsciiDoc, and terminal copy/paste, above. `make check` green, `-race` green on `app` and `terminal`, quality gate #40 PASS, 0 smells. Not committed. Nothing in it changes an editor's code; re-pinning is cosmetic but every editor shows the defect until it does.
190 197
198+## State as of 2026-09-29
199+
200+- **Remote editing (`-ssh`) is implemented but unreleased and uncommitted**, on `main`: `fsys/`, `fsys/fsystest/`, `remote/`, `app/remote.go`, `tools.StartProgram`, and the routing through `fsys` above. `go test ./...` green (root `release_test` included, since it runs inside the module); `gofmt`/`go vet` clean. **The editors need this released as v1.0.5** — until then they build only under a `go.work`, and their `release_test` fails (see their memories).
201+- **Verified for real**: `remote/integration_test.go` against the Docker sshd in `turbo-editors/demo-remote-machine` (`TURBO_CORE_SSH_KEY=… go test ./remote -run Real`), and `turbo-python -ssh` driven in a pty (pyte) with **password** login: prompt before the screen, remote file opened/edited/saved (checked on the machine), F9 tree, a popup tool printing `remote-machine`, F8 bash on the machine, status `⇄ … LSP: off`, clean exit, no master or socket left.
202+- **Quality gate NOT run**: `qlty` is not installed in this sandbox (and the skill's script is under `.claude/skills/quality/`, not `~/.claude/skills/`). Per the skill, not installed by hand. Run it before tagging v1.0.5.
203+- **Docs**: EN+FR `reference/fsys.md`, `reference/remote.md`, `how-to/add-remote-editing.md`, `explanation/remote-editing.md`; packages, app, architecture, index, run-the-tests updated; package READMEs for `fsys`, `remote` + updates to app, tools, buffer, projectfile, filetree, settings, snippets. `docs/diagrams/packages.drawio` **regenerated** from `go list` (22 packages, 4 external modules, 61 arrows) — the old one had drifted (no `configrepo`, no `fsys`).
204+
191 ## Not yet established205 ## Not yet established
192 206
207+- **Remote editing has never run with a macOS client, a macOS server, or through a real `ProxyJump`.** Verified Linux→Linux (Docker) only. The control socket path is shortened to `/tmp` when `os.TempDir()` is longer than 60 bytes, which is the macOS case, but untested there.
208+- **LSP and agents over ssh: designed, not built.** Run them on the remote machine via `ssh -S … -T dest "cd DIR && exec <server|agent>"` (stdio); a local one on remote files is wrong. Files to change are listed in `handoffs/2026-09-29-ssh-remote-editing.md`. Awaiting the user's decision; remote LSP recommended first.
209+- **A dropped connection mid-session is unhandled beyond errors**: saves then fail with the SFTP error in a dialog; there is no reconnect.
210+
193 - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.211 - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side.
194 - **The `/` and `@` pickers have only been driven by tests.** Commands, hints, embedded and linked mentions are all asserted on the wire against the fake agent, and the popup on a simulation screen, but no real agent has announced a command to this client yet — `docker agent` was not available in the sandbox on 2026-09-15 — and the user's own `mini-me` agent (`mm -acp`, which Zed discovers commands from) has never been pointed at an agent window. Whether its `available_commands_update` arrives before or after `session/new` returns, and whether it embeds context, are both unknown.212 - **The `/` and `@` pickers have only been driven by tests.** Commands, hints, embedded and linked mentions are all asserted on the wire against the fake agent, and the popup on a simulation screen, but no real agent has announced a command to this client yet — `docker agent` was not available in the sandbox on 2026-09-15 — and the user's own `mini-me` agent (`mm -acp`, which Zed discovers commands from) has never been pointed at an agent window. Whether its `available_commands_update` arrives before or after `session/new` returns, and whether it embeds context, are both unknown.
195 - **`release_test.go` fails in this sandbox for a reason that is not the code.** It copies the module with `cp -r`, and the sandbox's filesystem returns NUL bytes from `cp` for some recently written inodes (`acp/picker.go`, `app/agent_files.go` on 2026-09-15). Recreating the files at fresh inodes did **not** clear it this time. `cat`, `git`, `gofmt`, `go build` and `go test ./acp ./app` all read the right bytes.213 - **`release_test.go` fails in this sandbox for a reason that is not the code.** It copies the module with `cp -r`, and the sandbox's filesystem returns NUL bytes from `cp` for some recently written inodes (`acp/picker.go`, `app/agent_files.go` on 2026-09-15). Recreating the files at fresh inodes did **not** clear it this time. `cat`, `git`, `gofmt`, `go build` and `go test ./acp ./app` all read the right bytes.
modified README.md +5 -3
@@ -2,7 +2,7 @@
22
33 The library every Turbo editor is built on.
44
5-turbo-core is a Turbo C-style terminal IDE with a hole where the language goes: a menu bar, movable overlapping windows, modal dialogs, mouse support, loadable TOML themes, completion over LSP, terminal windows running a real shell, per-project settings, a project tree, snippets, and a menu of the commands a project runs on itself. None of it knows what language is being edited.
5+turbo-core is a Turbo C-style terminal IDE with a hole where the language goes: a menu bar, movable overlapping windows, modal dialogs, mouse support, loadable TOML themes, completion over LSP, terminal windows running a real shell, per-project settings, a project tree, snippets, a menu of the commands a project runs on itself, and editing another machine's files over ssh. None of it knows what language is being edited.
66
77 An editor built on it is a command, a `profile.Profile`, and a scanner:
88
@@ -45,13 +45,15 @@ Go 1.26.5 or later.
4545
4646 ## What is in it
4747
48-Sixteen packages. Dependencies run strictly downwards, with no cycles and no interface indirection introduced to prevent one.
48+Twenty-one packages. Dependencies run strictly downwards, with no cycles and no interface indirection introduced to prevent one.
4949
5050 | Package | What it holds |
5151 | --- | --- |
5252 | `profile` | The editor's identity: name, slug, language server, starter templates |
5353 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search |
5454 | `projectfile` | Atomic writes of a project's own TOML files |
55+| `fsys` | The one door to the project's files — the local disk, or another machine's after `fsys.Use` |
56+| `remote` | Another machine over the `ssh` command: one shared connection, files over SFTP, remote shells and tools — every editor's `-ssh user@machine[:dir]` |
5557 | `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone |
5658 | `version` | What build of itself a binary is |
5759 | `lsp` | LSP framing, JSON-RPC 2.0, the child process |
@@ -88,7 +90,7 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
8890
8991 Full documentation in **English** and **French**, following [Diátaxis](https://diataxis.fr):
9092
91-- [English](docs/en/README.md) — tutorial, five how-to guides, four reference pages, two explanations
93+- [English](docs/en/README.md) — a tutorial, seven how-to guides, six reference pages, three explanations
9294 - [Français](docs/fr/README.md) — le même, en français
9395
9496 ## Licence
@@ -2,7 +2,7 @@
2 2
3 The library every Turbo editor is built on.3 The library every Turbo editor is built on.
4 4
5-turbo-core is a Turbo C-style terminal IDE with a hole where the language goes: a menu bar, movable overlapping windows, modal dialogs, mouse support, loadable TOML themes, completion over LSP, terminal windows running a real shell, per-project settings, a project tree, snippets, and a menu of the commands a project runs on itself. None of it knows what language is being edited.5+turbo-core is a Turbo C-style terminal IDE with a hole where the language goes: a menu bar, movable overlapping windows, modal dialogs, mouse support, loadable TOML themes, completion over LSP, terminal windows running a real shell, per-project settings, a project tree, snippets, a menu of the commands a project runs on itself, and editing another machine's files over ssh. None of it knows what language is being edited.
6 6
7 An editor built on it is a command, a `profile.Profile`, and a scanner:7 An editor built on it is a command, a `profile.Profile`, and a scanner:
8 8
@@ -45,13 +45,15 @@ Go 1.26.5 or later.
45 45
46 ## What is in it46 ## What is in it
47 47
48-Sixteen packages. Dependencies run strictly downwards, with no cycles and no interface indirection introduced to prevent one.48+Twenty-one packages. Dependencies run strictly downwards, with no cycles and no interface indirection introduced to prevent one.
49 49
50 | Package | What it holds |50 | Package | What it holds |
51 | --- | --- |51 | --- | --- |
52 | `profile` | The editor's identity: name, slug, language server, starter templates |52 | `profile` | The editor's identity: name, slug, language server, starter templates |
53 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search |53 | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search |
54 | `projectfile` | Atomic writes of a project's own TOML files |54 | `projectfile` | Atomic writes of a project's own TOML files |
55+| `fsys` | The one door to the project's files — the local disk, or another machine's after `fsys.Use` |
56+| `remote` | Another machine over the `ssh` command: one shared connection, files over SFTP, remote shells and tools — every editor's `-ssh user@machine[:dir]` |
55 | `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone |57 | `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone |
56 | `version` | What build of itself a binary is |58 | `version` | What build of itself a binary is |
57 | `lsp` | LSP framing, JSON-RPC 2.0, the child process |59 | `lsp` | LSP framing, JSON-RPC 2.0, the child process |
@@ -88,7 +90,7 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .
88 90
89 Full documentation in **English** and **French**, following [Diátaxis](https://diataxis.fr):91 Full documentation in **English** and **French**, following [Diátaxis](https://diataxis.fr):
90 92
91-- [English](docs/en/README.md) — tutorial, five how-to guides, four reference pages, two explanations93+- [English](docs/en/README.md) — a tutorial, seven how-to guides, six reference pages, three explanations
92 - [Français](docs/fr/README.md) — le même, en français94 - [Français](docs/fr/README.md) — le même, en français
93 95
94 ## Licence96 ## Licence
modified app/README.md +5 -1
@@ -44,6 +44,10 @@ Two things are this package's rather than `acp`'s, and both are about the editor
4444 - **The permission dialog.** `session/request_permission` arrives on the session's reading goroutine; `recordPermission` puts it in a queue and wakes the loop, and `askNextPermission` opens the box on the next turn. One at a time — `pendingPermissions.asking` holds the rest back — because two agents asking at once would stack two modals over each other. Escape means the agent's *own* reject option, not a cancellation: the turn is still running and the agent may carry on without what it asked for.
4545 - **The file methods.** `readForAgent` answers from the **buffer** when the file is open and modified, so the agent sees the edit you just made rather than the version on disk. `writeForAgent` puts the agent's text into the buffer and leaves it unsaved, so the change is in front of you, undoable with `Ctrl-Z` and yours to keep with `F2`. Both take `agentFiles`, because they run on a session's goroutine while the event loop may be writing the same buffers.
4646
47+## Another machine
48+
49+`UseRemote(r)` says the project is somewhere else; `fsys` has already been switched there by the command, so the files need nothing more. What changes here: `NewTerminal` starts `r.ShellCommand(dir)` instead of the local shell (`shellOptions`), a tool runs `r.RunCommand(dir, command, …)` — through `tools.StartProgram` when captured, in a terminal window otherwise (`commandOptions`, `startTool`) — and the status bar starts with `⇄ user@host`. Agent windows are refused with a message (`refuseAgentsOverSSH`), and the Agent menu holds one disabled line saying why: an agent would read and write this machine's files while the screen shows the other's. `app` knows the machine only as the three-method `Remote` interface, so `remote_test.go` drives it with a fake that runs `/bin/sh` locally.
50+
4751 ## Project settings and automatic saving
4852
4953 A project's settings file is read by `main` and handed here through `UseSettings`, which applies autosave and remembers the path. The theme is **not** applied here: `main` resolves it first, because a `-theme` flag outranks the project's choice and only `main` knows whether one was given.
@@ -66,7 +70,7 @@ Two consequences worth knowing: a failed save clears the deadline **before** wri
6670
6771 `a.treeWindow` and `a.treeView` are a pair of fields rather than a map, because there is at most one tree: the root is fixed at start-up, so a second view of it would have nothing to distinguish it. `ProjectTree` raises the existing window instead of opening another, the same way opening an already-open file raises its window.
6872
69-The tree is rooted at `os.Getwd()` — the same rule the settings file follows, so "the project" means one thing throughout the editor. `refreshTree` runs after every save, which is the one moment the editor knows the project changed; everything else is the user's `F5`.
73+The tree is rooted at `fsys.Getwd()` — the same rule the settings file follows, so "the project" means one thing throughout the editor. `refreshTree` runs after every save, which is the one moment the editor knows the project changed; everything else is the user's `F5`.
7074
7175 ## The Go menu, and putting files back afterwards
7276
@@ -44,6 +44,10 @@ Two things are this package's rather than `acp`'s, and both are about the editor
44 - **The permission dialog.** `session/request_permission` arrives on the session's reading goroutine; `recordPermission` puts it in a queue and wakes the loop, and `askNextPermission` opens the box on the next turn. One at a time — `pendingPermissions.asking` holds the rest back — because two agents asking at once would stack two modals over each other. Escape means the agent's *own* reject option, not a cancellation: the turn is still running and the agent may carry on without what it asked for.44 - **The permission dialog.** `session/request_permission` arrives on the session's reading goroutine; `recordPermission` puts it in a queue and wakes the loop, and `askNextPermission` opens the box on the next turn. One at a time — `pendingPermissions.asking` holds the rest back — because two agents asking at once would stack two modals over each other. Escape means the agent's *own* reject option, not a cancellation: the turn is still running and the agent may carry on without what it asked for.
45 - **The file methods.** `readForAgent` answers from the **buffer** when the file is open and modified, so the agent sees the edit you just made rather than the version on disk. `writeForAgent` puts the agent's text into the buffer and leaves it unsaved, so the change is in front of you, undoable with `Ctrl-Z` and yours to keep with `F2`. Both take `agentFiles`, because they run on a session's goroutine while the event loop may be writing the same buffers.45 - **The file methods.** `readForAgent` answers from the **buffer** when the file is open and modified, so the agent sees the edit you just made rather than the version on disk. `writeForAgent` puts the agent's text into the buffer and leaves it unsaved, so the change is in front of you, undoable with `Ctrl-Z` and yours to keep with `F2`. Both take `agentFiles`, because they run on a session's goroutine while the event loop may be writing the same buffers.
46 46
47+## Another machine
48+
49+`UseRemote(r)` says the project is somewhere else; `fsys` has already been switched there by the command, so the files need nothing more. What changes here: `NewTerminal` starts `r.ShellCommand(dir)` instead of the local shell (`shellOptions`), a tool runs `r.RunCommand(dir, command, …)` — through `tools.StartProgram` when captured, in a terminal window otherwise (`commandOptions`, `startTool`) — and the status bar starts with `⇄ user@host`. Agent windows are refused with a message (`refuseAgentsOverSSH`), and the Agent menu holds one disabled line saying why: an agent would read and write this machine's files while the screen shows the other's. `app` knows the machine only as the three-method `Remote` interface, so `remote_test.go` drives it with a fake that runs `/bin/sh` locally.
50+
47 ## Project settings and automatic saving51 ## Project settings and automatic saving
48 52
49 A project's settings file is read by `main` and handed here through `UseSettings`, which applies autosave and remembers the path. The theme is **not** applied here: `main` resolves it first, because a `-theme` flag outranks the project's choice and only `main` knows whether one was given.53 A project's settings file is read by `main` and handed here through `UseSettings`, which applies autosave and remembers the path. The theme is **not** applied here: `main` resolves it first, because a `-theme` flag outranks the project's choice and only `main` knows whether one was given.
@@ -66,7 +70,7 @@ Two consequences worth knowing: a failed save clears the deadline **before** wri
66 70
67 `a.treeWindow` and `a.treeView` are a pair of fields rather than a map, because there is at most one tree: the root is fixed at start-up, so a second view of it would have nothing to distinguish it. `ProjectTree` raises the existing window instead of opening another, the same way opening an already-open file raises its window.71 `a.treeWindow` and `a.treeView` are a pair of fields rather than a map, because there is at most one tree: the root is fixed at start-up, so a second view of it would have nothing to distinguish it. `ProjectTree` raises the existing window instead of opening another, the same way opening an already-open file raises its window.
68 72
69-The tree is rooted at `os.Getwd()` — the same rule the settings file follows, so "the project" means one thing throughout the editor. `refreshTree` runs after every save, which is the one moment the editor knows the project changed; everything else is the user's `F5`.73+The tree is rooted at `fsys.Getwd()` — the same rule the settings file follows, so "the project" means one thing throughout the editor. `refreshTree` runs after every save, which is the one moment the editor knows the project changed; everything else is the user's `F5`.
70 74
71 ## The Go menu, and putting files back afterwards75 ## The Go menu, and putting files back afterwards
72 76
modified app/actions_file.go +2 -2
@@ -4,11 +4,11 @@ package app
44
55 import (
66 "fmt"
7- "os"
87 "path/filepath"
98
109 "rickub.com/turbo-editors/turbo-core/buffer"
1110 "rickub.com/turbo-editors/turbo-core/editor"
11+ "rickub.com/turbo-editors/turbo-core/fsys"
1212 "rickub.com/turbo-editors/turbo-core/lsp"
1313 "rickub.com/turbo-editors/turbo-core/ui"
1414 )
@@ -185,7 +185,7 @@ func (a *App) save(view *editor.View, path string) {
185185 // fileExists reports whether there is already a file at a path — asked before
186186 // a write, so that afterwards the save knows whether it created the file.
187187 func fileExists(path string) bool {
188- _, err := os.Stat(path)
188+ _, err := fsys.Stat(path)
189189 return err == nil
190190 }
191191
@@ -4,11 +4,11 @@ package app
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7- "os"
8 "path/filepath"7 "path/filepath"
9 8
10 "rickub.com/turbo-editors/turbo-core/buffer"9 "rickub.com/turbo-editors/turbo-core/buffer"
11 "rickub.com/turbo-editors/turbo-core/editor"10 "rickub.com/turbo-editors/turbo-core/editor"
11+ "rickub.com/turbo-editors/turbo-core/fsys"
12 "rickub.com/turbo-editors/turbo-core/lsp"12 "rickub.com/turbo-editors/turbo-core/lsp"
13 "rickub.com/turbo-editors/turbo-core/ui"13 "rickub.com/turbo-editors/turbo-core/ui"
14 )14 )
@@ -185,7 +185,7 @@ func (a *App) save(view *editor.View, path string) {
185 // fileExists reports whether there is already a file at a path — asked before185 // fileExists reports whether there is already a file at a path — asked before
186 // a write, so that afterwards the save knows whether it created the file.186 // a write, so that afterwards the save knows whether it created the file.
187 func fileExists(path string) bool {187 func fileExists(path string) bool {
188- _, err := os.Stat(path)188+ _, err := fsys.Stat(path)
189 return err == nil189 return err == nil
190 }190 }
191 191
modified app/agents.go +9 -0
@@ -28,6 +28,9 @@ type agentWindow struct {
2828 // that window. Opening the same agent twice gives two independent sessions,
2929 // the way two terminals are two shells.
3030 func (a *App) NewAgent(name string) {
31+ if a.refuseAgentsOverSSH() {
32+ return
33+ }
3134 list, err := a.loadAgents()
3235 if err != nil {
3336 a.ShowMessage("Agents", err.Error())
@@ -268,6 +271,9 @@ type agentFileLock = sync.Mutex
268271 // command line came out as, and whatever the agent itself said on its standard
269272 // error, which is where a misconfigured model endpoint reports itself.
270273 func (a *App) AgentStatus() {
274+ if a.refuseAgentsOverSSH() {
275+ return
276+ }
271277 a.ShowMessage("Agent status", a.agentReport())
272278 }
273279
@@ -374,6 +380,9 @@ func lastLines(lines []string, n int) []string {
374380 // format is learnt, and pointing it at your own agent is the first thing
375381 // anybody does. A project that already has one is opened unchanged.
376382 func (a *App) CreateAgentsFile() {
383+ if a.refuseAgentsOverSSH() {
384+ return
385+ }
377386 create := func(project string) (string, error) { return acp.Create(a.profile, project) }
378387 a.createProjectFile("Agents", create, acp.ErrExists)
379388 }
@@ -28,6 +28,9 @@ type agentWindow struct {
28 // that window. Opening the same agent twice gives two independent sessions,28 // that window. Opening the same agent twice gives two independent sessions,
29 // the way two terminals are two shells.29 // the way two terminals are two shells.
30 func (a *App) NewAgent(name string) {30 func (a *App) NewAgent(name string) {
31+ if a.refuseAgentsOverSSH() {
32+ return
33+ }
31 list, err := a.loadAgents()34 list, err := a.loadAgents()
32 if err != nil {35 if err != nil {
33 a.ShowMessage("Agents", err.Error())36 a.ShowMessage("Agents", err.Error())
@@ -268,6 +271,9 @@ type agentFileLock = sync.Mutex
268 // command line came out as, and whatever the agent itself said on its standard271 // command line came out as, and whatever the agent itself said on its standard
269 // error, which is where a misconfigured model endpoint reports itself.272 // error, which is where a misconfigured model endpoint reports itself.
270 func (a *App) AgentStatus() {273 func (a *App) AgentStatus() {
274+ if a.refuseAgentsOverSSH() {
275+ return
276+ }
271 a.ShowMessage("Agent status", a.agentReport())277 a.ShowMessage("Agent status", a.agentReport())
272 }278 }
273 279
@@ -374,6 +380,9 @@ func lastLines(lines []string, n int) []string {
374 // format is learnt, and pointing it at your own agent is the first thing380 // format is learnt, and pointing it at your own agent is the first thing
375 // anybody does. A project that already has one is opened unchanged.381 // anybody does. A project that already has one is opened unchanged.
376 func (a *App) CreateAgentsFile() {382 func (a *App) CreateAgentsFile() {
383+ if a.refuseAgentsOverSSH() {
384+ return
385+ }
377 create := func(project string) (string, error) { return acp.Create(a.profile, project) }386 create := func(project string) (string, error) { return acp.Create(a.profile, project) }
378 a.createProjectFile("Agents", create, acp.ErrExists)387 a.createProjectFile("Agents", create, acp.ErrExists)
379 }388 }
modified app/app.go +6 -2
@@ -79,6 +79,10 @@ type App struct {
7979 // running is the command whose output is showing in a dialog, when one is.
8080 running *toolRun
8181
82+ // remote is the machine the project is on, when it is not this one. Nil
83+ // means local, which is every editor that was not started with -ssh.
84+ remote Remote
85+
8286 // toolValues is what was last typed into a tool's parameters box, by tool
8387 // name and then by label. It lives for the session only: a value somebody
8488 // typed this afternoon is not a decision the project made, so it does not
@@ -399,7 +403,7 @@ func (a *App) draw() {
399403 func (a *App) updateStatus() {
400404 view := a.activeView()
401405 if view == nil {
402- a.status.SetExtra(a.language.Status())
406+ a.status.SetExtra(a.connectionStatus())
403407 return
404408 }
405409
@@ -407,7 +411,7 @@ func (a *App) updateStatus() {
407411 if diagnostic, ok := a.language.FirstError(view.Buffer().Path()); ok {
408412 extra = fmt.Sprintf("%s ⚠ %s", extra, diagnostic.Message)
409413 } else {
410- extra = fmt.Sprintf("%s %s", extra, a.language.Status())
414+ extra = fmt.Sprintf("%s %s", extra, a.connectionStatus())
411415 }
412416 a.status.SetExtra(extra)
413417 }
@@ -79,6 +79,10 @@ type App struct {
79 // running is the command whose output is showing in a dialog, when one is.79 // running is the command whose output is showing in a dialog, when one is.
80 running *toolRun80 running *toolRun
81 81
82+ // remote is the machine the project is on, when it is not this one. Nil
83+ // means local, which is every editor that was not started with -ssh.
84+ remote Remote
85+
82 // toolValues is what was last typed into a tool's parameters box, by tool86 // toolValues is what was last typed into a tool's parameters box, by tool
83 // name and then by label. It lives for the session only: a value somebody87 // name and then by label. It lives for the session only: a value somebody
84 // typed this afternoon is not a decision the project made, so it does not88 // typed this afternoon is not a decision the project made, so it does not
@@ -399,7 +403,7 @@ func (a *App) draw() {
399 func (a *App) updateStatus() {403 func (a *App) updateStatus() {
400 view := a.activeView()404 view := a.activeView()
401 if view == nil {405 if view == nil {
402- a.status.SetExtra(a.language.Status())406+ a.status.SetExtra(a.connectionStatus())
403 return407 return
404 }408 }
405 409
@@ -407,7 +411,7 @@ func (a *App) updateStatus() {
407 if diagnostic, ok := a.language.FirstError(view.Buffer().Path()); ok {411 if diagnostic, ok := a.language.FirstError(view.Buffer().Path()); ok {
408 extra = fmt.Sprintf("%s ⚠ %s", extra, diagnostic.Message)412 extra = fmt.Sprintf("%s ⚠ %s", extra, diagnostic.Message)
409 } else {413 } else {
410- extra = fmt.Sprintf("%s %s", extra, a.language.Status())414+ extra = fmt.Sprintf("%s %s", extra, a.connectionStatus())
411 }415 }
412 a.status.SetExtra(extra)416 a.status.SetExtra(extra)
413 }417 }
modified app/code.go +2 -2
@@ -7,10 +7,10 @@ import (
77 "context"
88 "errors"
99 "fmt"
10- "os"
1110 "path/filepath"
1211 "strings"
1312
13+ "rickub.com/turbo-editors/turbo-core/fsys"
1414 "rickub.com/turbo-editors/turbo-core/lsp"
1515 "rickub.com/turbo-editors/turbo-core/ui"
1616 )
@@ -134,7 +134,7 @@ func (a *App) linesOf(path string) []string {
134134 return strings.Split(view.Buffer().Text(), "\n")
135135 }
136136 }
137- data, err := os.ReadFile(path)
137+ data, err := fsys.ReadFile(path)
138138 if err != nil {
139139 return nil
140140 }
@@ -7,10 +7,10 @@ import (
7 "context"7 "context"
8 "errors"8 "errors"
9 "fmt"9 "fmt"
10- "os"
11 "path/filepath"10 "path/filepath"
12 "strings"11 "strings"
13 12
13+ "rickub.com/turbo-editors/turbo-core/fsys"
14 "rickub.com/turbo-editors/turbo-core/lsp"14 "rickub.com/turbo-editors/turbo-core/lsp"
15 "rickub.com/turbo-editors/turbo-core/ui"15 "rickub.com/turbo-editors/turbo-core/ui"
16 )16 )
@@ -134,7 +134,7 @@ func (a *App) linesOf(path string) []string {
134 return strings.Split(view.Buffer().Text(), "\n")134 return strings.Split(view.Buffer().Text(), "\n")
135 }135 }
136 }136 }
137- data, err := os.ReadFile(path)137+ data, err := fsys.ReadFile(path)
138 if err != nil {138 if err != nil {
139 return nil139 return nil
140 }140 }
modified app/dialogs.go +8 -7
@@ -1,11 +1,12 @@
11 package app
22
33 import (
4- "os"
4+ "io/fs"
55 "path/filepath"
66 "sort"
77 "strings"
88
9+ "rickub.com/turbo-editors/turbo-core/fsys"
910 "rickub.com/turbo-editors/turbo-core/ui"
1011 )
1112
@@ -66,17 +67,17 @@ func NewFileDialog(title, startPath string, screen ui.Rect) *FileDialog {
6667 // splitStartPath turns the path a dialog opens on into a directory and a name.
6768 func splitStartPath(startPath string) (directory, name string) {
6869 if startPath == "" {
69- if working, err := os.Getwd(); err == nil {
70+ if working, err := fsys.Getwd(); err == nil {
7071 return working, ""
7172 }
7273 return ".", ""
7374 }
7475
75- absolute, err := filepath.Abs(startPath)
76+ absolute, err := fsys.Abs(startPath)
7677 if err != nil {
7778 absolute = startPath
7879 }
79- if info, err := os.Stat(absolute); err == nil && info.IsDir() {
80+ if info, err := fsys.Stat(absolute); err == nil && info.IsDir() {
8081 return absolute, ""
8182 }
8283 return filepath.Dir(absolute), filepath.Base(absolute)
@@ -145,7 +146,7 @@ func (f *FileDialog) confirm() {
145146 f.choose()
146147 return
147148 }
148- if info, err := os.Stat(path); err == nil && info.IsDir() {
149+ if info, err := fsys.Stat(path); err == nil && info.IsDir() {
149150 f.enter(path)
150151 return
151152 }
@@ -183,7 +184,7 @@ func (f *FileDialog) enter(directory string) {
183184 func (f *FileDialog) readDirectory() {
184185 f.dialog.SetTitle(f.title + " — " + shortenPath(f.directory, fileDialogWidth-len(f.title)-8))
185186
186- entries, err := os.ReadDir(f.directory)
187+ entries, err := fsys.ReadDir(f.directory)
187188 if err != nil {
188189 f.entries = []string{parentEntry}
189190 f.list.SetItems(f.entries)
@@ -196,7 +197,7 @@ func (f *FileDialog) readDirectory() {
196197
197198 // directoryEntries returns the names to list: the parent, then the
198199 // directories, then the files, each group sorted.
199-func directoryEntries(entries []os.DirEntry) []string {
200+func directoryEntries(entries []fs.DirEntry) []string {
200201 var directories, files []string
201202 for _, entry := range entries {
202203 if strings.HasPrefix(entry.Name(), ".") {
@@ -1,11 +1,12 @@
1 package app1 package app
2 2
3 import (3 import (
4- "os"4+ "io/fs"
5 "path/filepath"5 "path/filepath"
6 "sort"6 "sort"
7 "strings"7 "strings"
8 8
9+ "rickub.com/turbo-editors/turbo-core/fsys"
9 "rickub.com/turbo-editors/turbo-core/ui"10 "rickub.com/turbo-editors/turbo-core/ui"
10 )11 )
11 12
@@ -66,17 +67,17 @@ func NewFileDialog(title, startPath string, screen ui.Rect) *FileDialog {
66 // splitStartPath turns the path a dialog opens on into a directory and a name.67 // splitStartPath turns the path a dialog opens on into a directory and a name.
67 func splitStartPath(startPath string) (directory, name string) {68 func splitStartPath(startPath string) (directory, name string) {
68 if startPath == "" {69 if startPath == "" {
69- if working, err := os.Getwd(); err == nil {70+ if working, err := fsys.Getwd(); err == nil {
70 return working, ""71 return working, ""
71 }72 }
72 return ".", ""73 return ".", ""
73 }74 }
74 75
75- absolute, err := filepath.Abs(startPath)76+ absolute, err := fsys.Abs(startPath)
76 if err != nil {77 if err != nil {
77 absolute = startPath78 absolute = startPath
78 }79 }
79- if info, err := os.Stat(absolute); err == nil && info.IsDir() {80+ if info, err := fsys.Stat(absolute); err == nil && info.IsDir() {
80 return absolute, ""81 return absolute, ""
81 }82 }
82 return filepath.Dir(absolute), filepath.Base(absolute)83 return filepath.Dir(absolute), filepath.Base(absolute)
@@ -145,7 +146,7 @@ func (f *FileDialog) confirm() {
145 f.choose()146 f.choose()
146 return147 return
147 }148 }
148- if info, err := os.Stat(path); err == nil && info.IsDir() {149+ if info, err := fsys.Stat(path); err == nil && info.IsDir() {
149 f.enter(path)150 f.enter(path)
150 return151 return
151 }152 }
@@ -183,7 +184,7 @@ func (f *FileDialog) enter(directory string) {
183 func (f *FileDialog) readDirectory() {184 func (f *FileDialog) readDirectory() {
184 f.dialog.SetTitle(f.title + " — " + shortenPath(f.directory, fileDialogWidth-len(f.title)-8))185 f.dialog.SetTitle(f.title + " — " + shortenPath(f.directory, fileDialogWidth-len(f.title)-8))
185 186
186- entries, err := os.ReadDir(f.directory)187+ entries, err := fsys.ReadDir(f.directory)
187 if err != nil {188 if err != nil {
188 f.entries = []string{parentEntry}189 f.entries = []string{parentEntry}
189 f.list.SetItems(f.entries)190 f.list.SetItems(f.entries)
@@ -196,7 +197,7 @@ func (f *FileDialog) readDirectory() {
196 197
197 // directoryEntries returns the names to list: the parent, then the198 // directoryEntries returns the names to list: the parent, then the
198 // directories, then the files, each group sorted.199 // directories, then the files, each group sorted.
199-func directoryEntries(entries []os.DirEntry) []string {200+func directoryEntries(entries []fs.DirEntry) []string {
200 var directories, files []string201 var directories, files []string
201 for _, entry := range entries {202 for _, entry := range entries {
202 if strings.HasPrefix(entry.Name(), ".") {203 if strings.HasPrefix(entry.Name(), ".") {
added app/fsys_test.go +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+// newMemoryApp returns an editor whose files exist only in memory, as they
11+// would only exist on the far side of -ssh. Anything it finds or writes there
12+// proves the editor went through fsys rather than to the local disk.
13+func newMemoryApp(t *testing.T) (*App, *fsystest.Memory) {
14+ t.Helper()
15+ memory := fsystest.New("/remote/project")
16+ previous := fsys.Use(memory)
17+ t.Cleanup(func() { fsys.Use(previous) })
18+
19+ a, _ := newTestApp(t)
20+ return a, memory
21+}
22+
23+func TestTheProjectTreeIsRootedInTheCurrentFileSystem(t *testing.T) {
24+ a, memory := newMemoryApp(t)
25+ memory.AddFile("/remote/project/main.py", "")
26+
27+ a.ProjectTree()
28+
29+ if got := a.Desktop().Active().Title(); got != "project" {
30+ t.Errorf("the tree window is called %q, want project", got)
31+ }
32+}
33+
34+func TestAFileIsOpenedAndSavedThroughTheCurrentFileSystem(t *testing.T) {
35+ a, memory := newMemoryApp(t)
36+ memory.AddFile("/remote/project/main.py", "print(1)\n")
37+
38+ a.Open("main.py")
39+ if got := activeBuffer(t, a).Text(); got != "print(1)\n" {
40+ t.Fatalf("the buffer holds %q", got)
41+ }
42+ typeText(a, "# ")
43+ a.save(a.ActiveView(), activeBuffer(t, a).Path())
44+
45+ if got, _ := memory.File("/remote/project/main.py"); got != "# print(1)\n" {
46+ t.Errorf("the file now holds %q", got)
47+ }
48+}
49+
50+func TestTheToolsMenuReadsTheProjectsToolsFileThroughTheCurrentFileSystem(t *testing.T) {
51+ a, memory := newMemoryApp(t)
52+ memory.AddFile("/remote/project/"+testProfile().ProjectDir()+"/tools.toml", "[[tool]]\nname = \"Run\"\ncommand = \"python3 main.py\"\n")
53+
54+ list, err := a.loadTools()
55+ if err != nil || list.Len() != 1 {
56+ t.Errorf("loadTools = %d tools, %v; want the one in the remote file", list.Len(), err)
57+ }
58+}
new file mode 100644
@@ -0,0 +1,58 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+// newMemoryApp returns an editor whose files exist only in memory, as they
11+// would only exist on the far side of -ssh. Anything it finds or writes there
12+// proves the editor went through fsys rather than to the local disk.
13+func newMemoryApp(t *testing.T) (*App, *fsystest.Memory) {
14+ t.Helper()
15+ memory := fsystest.New("/remote/project")
16+ previous := fsys.Use(memory)
17+ t.Cleanup(func() { fsys.Use(previous) })
18+
19+ a, _ := newTestApp(t)
20+ return a, memory
21+}
22+
23+func TestTheProjectTreeIsRootedInTheCurrentFileSystem(t *testing.T) {
24+ a, memory := newMemoryApp(t)
25+ memory.AddFile("/remote/project/main.py", "")
26+
27+ a.ProjectTree()
28+
29+ if got := a.Desktop().Active().Title(); got != "project" {
30+ t.Errorf("the tree window is called %q, want project", got)
31+ }
32+}
33+
34+func TestAFileIsOpenedAndSavedThroughTheCurrentFileSystem(t *testing.T) {
35+ a, memory := newMemoryApp(t)
36+ memory.AddFile("/remote/project/main.py", "print(1)\n")
37+
38+ a.Open("main.py")
39+ if got := activeBuffer(t, a).Text(); got != "print(1)\n" {
40+ t.Fatalf("the buffer holds %q", got)
41+ }
42+ typeText(a, "# ")
43+ a.save(a.ActiveView(), activeBuffer(t, a).Path())
44+
45+ if got, _ := memory.File("/remote/project/main.py"); got != "# print(1)\n" {
46+ t.Errorf("the file now holds %q", got)
47+ }
48+}
49+
50+func TestTheToolsMenuReadsTheProjectsToolsFileThroughTheCurrentFileSystem(t *testing.T) {
51+ a, memory := newMemoryApp(t)
52+ memory.AddFile("/remote/project/"+testProfile().ProjectDir()+"/tools.toml", "[[tool]]\nname = \"Run\"\ncommand = \"python3 main.py\"\n")
53+
54+ list, err := a.loadTools()
55+ if err != nil || list.Len() != 1 {
56+ t.Errorf("loadTools = %d tools, %v; want the one in the remote file", list.Len(), err)
57+ }
58+}
modified app/menus.go +4 -0
@@ -82,6 +82,10 @@ func (a *App) agentMenu() *ui.Menu {
8282 // agentItems builds the menu's contents, which are read from the file every
8383 // time it drops down so that editing acp.toml never needs a restart.
8484 func (a *App) agentItems() []*ui.MenuItem {
85+ if a.remote != nil {
86+ return []*ui.MenuItem{{Label: "(not available over ssh)", Enabled: never}}
87+ }
88+
8589 var items []*ui.MenuItem
8690
8791 list, err := a.loadAgents()
@@ -82,6 +82,10 @@ func (a *App) agentMenu() *ui.Menu {
82 // agentItems builds the menu's contents, which are read from the file every82 // agentItems builds the menu's contents, which are read from the file every
83 // time it drops down so that editing acp.toml never needs a restart.83 // time it drops down so that editing acp.toml never needs a restart.
84 func (a *App) agentItems() []*ui.MenuItem {84 func (a *App) agentItems() []*ui.MenuItem {
85+ if a.remote != nil {
86+ return []*ui.MenuItem{{Label: "(not available over ssh)", Enabled: never}}
87+ }
88+
85 var items []*ui.MenuItem89 var items []*ui.MenuItem
86 90
87 list, err := a.loadAgents()91 list, err := a.loadAgents()
modified app/project.go +8 -8
@@ -5,9 +5,9 @@ package app
55
66 import (
77 "errors"
8- "os"
98 "path/filepath"
109
10+ "rickub.com/turbo-editors/turbo-core/fsys"
1111 "rickub.com/turbo-editors/turbo-core/settings"
1212 "rickub.com/turbo-editors/turbo-core/snippets"
1313 "rickub.com/turbo-editors/turbo-core/tools"
@@ -42,7 +42,7 @@ func (a *App) CreateProjectSettings() {
4242 // for one just created and for one that already existed: a caller wanting to
4343 // remember the path wants it in both cases.
4444 func (a *App) createProjectFile(title string, create func(string) (string, error), alreadyThere error) (string, bool) {
45- project, err := os.Getwd()
45+ project, err := fsys.Getwd()
4646 if err != nil {
4747 a.ShowMessage(title, "Cannot tell which directory this is:\n"+err.Error())
4848 return "", false
@@ -122,7 +122,7 @@ func (a *App) snippetsFile() projectFile {
122122 // somebody's repository by itself. The item is greyed out in that case anyway —
123123 // the dialog is for the caller that is not a menu.
124124 func (a *App) openProjectFile(file projectFile) {
125- project, err := os.Getwd()
125+ project, err := fsys.Getwd()
126126 if err != nil || !file.exists(project) {
127127 a.ShowMessage(file.title, "This project has no "+file.relative+" yet.\n\n"+file.creator+" makes one.")
128128 return
@@ -132,7 +132,7 @@ func (a *App) openProjectFile(file projectFile) {
132132
133133 // hasProjectFile reports whether the project has one of them.
134134 func (a *App) hasProjectFile(file projectFile) bool {
135- project, err := os.Getwd()
135+ project, err := fsys.Getwd()
136136 return err == nil && file.exists(project)
137137 }
138138
@@ -188,7 +188,7 @@ func not(condition func() bool) func() bool {
188188 // this file, and a -theme flag given on the command line is the more explicit
189189 // statement of the two for the session it was given in.
190190 func (a *App) reapplySettings(path string) {
191- project, err := os.Getwd()
191+ project, err := fsys.Getwd()
192192 if err != nil || !samePath(path, settings.Path(a.profile, project)) {
193193 return
194194 }
@@ -223,11 +223,11 @@ func describeAutosave(s settings.Settings) string {
223223 // absolute paths so that "./.turbo-go/settings.toml" and the absolute spelling
224224 // of it are recognised as one file.
225225 func samePath(one, other string) bool {
226- oneAbs, err := filepath.Abs(one)
226+ oneAbs, err := fsys.Abs(one)
227227 if err != nil {
228228 return one == other
229229 }
230- otherAbs, err := filepath.Abs(other)
230+ otherAbs, err := fsys.Abs(other)
231231 if err != nil {
232232 return one == other
233233 }
@@ -253,7 +253,7 @@ func (a *App) rememberTheme(name string) {
253253 // it, because ".turbo-go/settings.toml" reads better in a status bar than the
254254 // absolute path of the same file.
255255 func (a *App) shortPath(path string) string {
256- working, err := os.Getwd()
256+ working, err := fsys.Getwd()
257257 if err != nil {
258258 return path
259259 }
@@ -5,9 +5,9 @@ package app
5 5
6 import (6 import (
7 "errors"7 "errors"
8- "os"
9 "path/filepath"8 "path/filepath"
10 9
10+ "rickub.com/turbo-editors/turbo-core/fsys"
11 "rickub.com/turbo-editors/turbo-core/settings"11 "rickub.com/turbo-editors/turbo-core/settings"
12 "rickub.com/turbo-editors/turbo-core/snippets"12 "rickub.com/turbo-editors/turbo-core/snippets"
13 "rickub.com/turbo-editors/turbo-core/tools"13 "rickub.com/turbo-editors/turbo-core/tools"
@@ -42,7 +42,7 @@ func (a *App) CreateProjectSettings() {
42 // for one just created and for one that already existed: a caller wanting to42 // for one just created and for one that already existed: a caller wanting to
43 // remember the path wants it in both cases.43 // remember the path wants it in both cases.
44 func (a *App) createProjectFile(title string, create func(string) (string, error), alreadyThere error) (string, bool) {44 func (a *App) createProjectFile(title string, create func(string) (string, error), alreadyThere error) (string, bool) {
45- project, err := os.Getwd()45+ project, err := fsys.Getwd()
46 if err != nil {46 if err != nil {
47 a.ShowMessage(title, "Cannot tell which directory this is:\n"+err.Error())47 a.ShowMessage(title, "Cannot tell which directory this is:\n"+err.Error())
48 return "", false48 return "", false
@@ -122,7 +122,7 @@ func (a *App) snippetsFile() projectFile {
122 // somebody's repository by itself. The item is greyed out in that case anyway —122 // somebody's repository by itself. The item is greyed out in that case anyway —
123 // the dialog is for the caller that is not a menu.123 // the dialog is for the caller that is not a menu.
124 func (a *App) openProjectFile(file projectFile) {124 func (a *App) openProjectFile(file projectFile) {
125- project, err := os.Getwd()125+ project, err := fsys.Getwd()
126 if err != nil || !file.exists(project) {126 if err != nil || !file.exists(project) {
127 a.ShowMessage(file.title, "This project has no "+file.relative+" yet.\n\n"+file.creator+" makes one.")127 a.ShowMessage(file.title, "This project has no "+file.relative+" yet.\n\n"+file.creator+" makes one.")
128 return128 return
@@ -132,7 +132,7 @@ func (a *App) openProjectFile(file projectFile) {
132 132
133 // hasProjectFile reports whether the project has one of them.133 // hasProjectFile reports whether the project has one of them.
134 func (a *App) hasProjectFile(file projectFile) bool {134 func (a *App) hasProjectFile(file projectFile) bool {
135- project, err := os.Getwd()135+ project, err := fsys.Getwd()
136 return err == nil && file.exists(project)136 return err == nil && file.exists(project)
137 }137 }
138 138
@@ -188,7 +188,7 @@ func not(condition func() bool) func() bool {
188 // this file, and a -theme flag given on the command line is the more explicit188 // this file, and a -theme flag given on the command line is the more explicit
189 // statement of the two for the session it was given in.189 // statement of the two for the session it was given in.
190 func (a *App) reapplySettings(path string) {190 func (a *App) reapplySettings(path string) {
191- project, err := os.Getwd()191+ project, err := fsys.Getwd()
192 if err != nil || !samePath(path, settings.Path(a.profile, project)) {192 if err != nil || !samePath(path, settings.Path(a.profile, project)) {
193 return193 return
194 }194 }
@@ -223,11 +223,11 @@ func describeAutosave(s settings.Settings) string {
223 // absolute paths so that "./.turbo-go/settings.toml" and the absolute spelling223 // absolute paths so that "./.turbo-go/settings.toml" and the absolute spelling
224 // of it are recognised as one file.224 // of it are recognised as one file.
225 func samePath(one, other string) bool {225 func samePath(one, other string) bool {
226- oneAbs, err := filepath.Abs(one)226+ oneAbs, err := fsys.Abs(one)
227 if err != nil {227 if err != nil {
228 return one == other228 return one == other
229 }229 }
230- otherAbs, err := filepath.Abs(other)230+ otherAbs, err := fsys.Abs(other)
231 if err != nil {231 if err != nil {
232 return one == other232 return one == other
233 }233 }
@@ -253,7 +253,7 @@ func (a *App) rememberTheme(name string) {
253 // it, because ".turbo-go/settings.toml" reads better in a status bar than the253 // it, because ".turbo-go/settings.toml" reads better in a status bar than the
254 // absolute path of the same file.254 // absolute path of the same file.
255 func (a *App) shortPath(path string) string {255 func (a *App) shortPath(path string) string {
256- working, err := os.Getwd()256+ working, err := fsys.Getwd()
257 if err != nil {257 if err != nil {
258 return path258 return path
259 }259 }
added app/remote.go +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+// Working on another machine: what changes in the editor when its files are
2+// reached over ssh.
3+
4+package app
5+
6+// Remote is what the editor needs from a machine it is editing over ssh:
7+// a name to show, and the command lines that open a shell or run a tool
8+// there. *remote.Session is one.
9+//
10+// The files themselves are not here. They come through fsys, which the
11+// command switches to the remote machine before the editor is made; by the
12+// time UseRemote is called, every file the editor reads is already remote.
13+type Remote interface {
14+ // Name is how the machine is shown: user@host.
15+ Name() string
16+ // ShellCommand is the command line of an interactive shell in dir.
17+ ShellCommand(dir string) []string
18+ // RunCommand is the command line that runs one command line in dir, with
19+ // a terminal on the far side when withTerminal is true.
20+ RunCommand(dir, command string, withTerminal bool) []string
21+}
22+
23+// UseRemote tells the editor its project is on another machine.
24+//
25+// From then on a terminal window is a shell over there, a tool runs over
26+// there, and the status bar names the machine. Agent windows are refused: an
27+// agent is a program on this machine, and one reading and writing the local
28+// disk while the editor shows the remote one would be editing a different
29+// project from the one on screen.
30+//
31+// No language server is started here; the command leaves that out when it
32+// connects, because the servers this editor knows how to find are on this
33+// machine and the files they would be asked about are not.
34+//
35+// session, _ := remote.Connect(ctx, target, remote.Options{})
36+// fsys.Use(session.FS())
37+// editor := app.New(screen, themeName, profile)
38+// editor.UseRemote(session)
39+func (a *App) UseRemote(r Remote) {
40+ a.remote = r
41+}
42+
43+// Remote returns the machine being edited, or nil when the files are local.
44+func (a *App) Remote() Remote { return a.remote }
45+
46+// agentsUnavailableOverSSH is what the agent menu and its commands say when
47+// the project is on another machine.
48+const agentsUnavailableOverSSH = "Agent windows are not available while\nediting over ssh.\n\nAn agent runs on this machine and would read\nand write this machine's files, not the ones\nthe editor is showing."
49+
50+// refuseAgentsOverSSH reports that agents are off, and whether it did.
51+func (a *App) refuseAgentsOverSSH() bool {
52+ if a.remote == nil {
53+ return false
54+ }
55+ a.ShowMessage("Agents", agentsUnavailableOverSSH)
56+ return true
57+}
58+
59+// connectionStatus is what the right of the status bar says about where the
60+// files are and the language server, when no window is open.
61+func (a *App) connectionStatus() string {
62+ if a.remote == nil {
63+ return a.language.Status()
64+ }
65+ return "⇄ " + a.remote.Name() + " " + a.language.Status()
66+}
new file mode 100644
@@ -0,0 +1,66 @@
1+// Working on another machine: what changes in the editor when its files are
2+// reached over ssh.
3+
4+package app
5+
6+// Remote is what the editor needs from a machine it is editing over ssh:
7+// a name to show, and the command lines that open a shell or run a tool
8+// there. *remote.Session is one.
9+//
10+// The files themselves are not here. They come through fsys, which the
11+// command switches to the remote machine before the editor is made; by the
12+// time UseRemote is called, every file the editor reads is already remote.
13+type Remote interface {
14+ // Name is how the machine is shown: user@host.
15+ Name() string
16+ // ShellCommand is the command line of an interactive shell in dir.
17+ ShellCommand(dir string) []string
18+ // RunCommand is the command line that runs one command line in dir, with
19+ // a terminal on the far side when withTerminal is true.
20+ RunCommand(dir, command string, withTerminal bool) []string
21+}
22+
23+// UseRemote tells the editor its project is on another machine.
24+//
25+// From then on a terminal window is a shell over there, a tool runs over
26+// there, and the status bar names the machine. Agent windows are refused: an
27+// agent is a program on this machine, and one reading and writing the local
28+// disk while the editor shows the remote one would be editing a different
29+// project from the one on screen.
30+//
31+// No language server is started here; the command leaves that out when it
32+// connects, because the servers this editor knows how to find are on this
33+// machine and the files they would be asked about are not.
34+//
35+// session, _ := remote.Connect(ctx, target, remote.Options{})
36+// fsys.Use(session.FS())
37+// editor := app.New(screen, themeName, profile)
38+// editor.UseRemote(session)
39+func (a *App) UseRemote(r Remote) {
40+ a.remote = r
41+}
42+
43+// Remote returns the machine being edited, or nil when the files are local.
44+func (a *App) Remote() Remote { return a.remote }
45+
46+// agentsUnavailableOverSSH is what the agent menu and its commands say when
47+// the project is on another machine.
48+const agentsUnavailableOverSSH = "Agent windows are not available while\nediting over ssh.\n\nAn agent runs on this machine and would read\nand write this machine's files, not the ones\nthe editor is showing."
49+
50+// refuseAgentsOverSSH reports that agents are off, and whether it did.
51+func (a *App) refuseAgentsOverSSH() bool {
52+ if a.remote == nil {
53+ return false
54+ }
55+ a.ShowMessage("Agents", agentsUnavailableOverSSH)
56+ return true
57+}
58+
59+// connectionStatus is what the right of the status bar says about where the
60+// files are and the language server, when no window is open.
61+func (a *App) connectionStatus() string {
62+ if a.remote == nil {
63+ return a.language.Status()
64+ }
65+ return "⇄ " + a.remote.Name() + " " + a.language.Status()
66+}
added app/remote_test.go +132 -0
new file mode 100644
@@ -0,0 +1,132 @@
1+package app
2+
3+import (
4+ "os"
5+ "slices"
6+ "strings"
7+ "testing"
8+
9+ "github.com/gdamore/tcell/v2"
10+
11+ "rickub.com/turbo-editors/turbo-core/tools"
12+)
13+
14+// fakeRemote stands in for an ssh session. Its command lines run /bin/sh on
15+// this machine, and say which directory and command they were given, so a
16+// test can see that the editor asked the remote side — not a local shell — to
17+// run something, and where.
18+type fakeRemote struct{}
19+
20+func (fakeRemote) Name() string { return "bob@machine" }
21+
22+func (fakeRemote) ShellCommand(dir string) []string {
23+ return []string{"/bin/sh", "-c", `echo "remote shell in $0"; sleep 30`, dir}
24+}
25+
26+func (fakeRemote) RunCommand(dir, command string, withTerminal bool) []string {
27+ return []string{"/bin/sh", "-c", `echo "remote run [$1] in $0 terminal=$2"`, dir, command, map[bool]string{true: "yes", false: "no"}[withTerminal]}
28+}
29+
30+// newRemoteApp returns an editor told its project is on the fake machine.
31+func newRemoteApp(t *testing.T) *App {
32+ t.Helper()
33+ a, _ := newToolsApp(t, "")
34+ a.UseRemote(fakeRemote{})
35+ return a
36+}
37+
38+func TestRemoteReturnsNilForALocalEditorAndTheMachineForARemoteOne(t *testing.T) {
39+ local, _ := newTestApp(t)
40+ if local.Remote() != nil {
41+ t.Errorf("Remote() = %v for an editor never told of a machine", local.Remote())
42+ }
43+ if newRemoteApp(t).Remote() == nil {
44+ t.Error("Remote() = nil after UseRemote")
45+ }
46+}
47+
48+func TestAPopupToolRunsOnTheRemoteMachineInTheProjectDirectory(t *testing.T) {
49+ a := newRemoteApp(t)
50+ root, _ := os.Getwd()
51+
52+ runAndDrain(t, a, tools.Tool{Name: "Test", Command: "pytest -q"})
53+
54+ lines := outputLines(a)
55+ want := "remote run [pytest -q] in " + root + " terminal=no"
56+ if !slices.ContainsFunc(lines, func(line string) bool { return strings.HasSuffix(line, want) || line == want }) {
57+ t.Errorf("output = %q, want a line %q", lines, want)
58+ }
59+ if got := a.running.dialog.Title(); !strings.Contains(got, "pytest -q") {
60+ t.Errorf("the dialog is called %q, want it named after the command, not after ssh", got)
61+ }
62+}
63+
64+func TestATerminalToolRunsOnTheRemoteMachineWithATerminal(t *testing.T) {
65+ skipWithoutPTY(t)
66+ a := newRemoteApp(t)
67+ t.Cleanup(a.closeTerminals)
68+
69+ options := a.commandOptions("python3", "/srv/app")
70+ if options.Shell != "/bin/sh" || options.Dir != "" || options.Args[len(options.Args)-1] != "yes" {
71+ t.Errorf("commandOptions = %+v, want the remote's command line with a terminal, started here", options)
72+ }
73+
74+ a.RunTool(tools.Tool{Name: "REPL", Command: "python3", Output: tools.OutputTerminal})
75+ if got := a.Desktop().Active().Title(); got != "python3" {
76+ t.Errorf("the window is called %q, want the command", got)
77+ }
78+}
79+
80+func TestATerminalWindowIsAShellOnTheRemoteMachine(t *testing.T) {
81+ skipWithoutPTY(t)
82+ a := newRemoteApp(t)
83+ t.Cleanup(a.closeTerminals)
84+ screen := a.screen.(tcell.SimulationScreen)
85+
86+ options, name := a.shellOptions("/home/bob/app")
87+ if name != "bob@machine" || options.Dir != "" || options.Args[len(options.Args)-1] != "/home/bob/app" {
88+ t.Errorf("shellOptions = %+v, %q", options, name)
89+ }
90+
91+ // The directory is the working directory, too long for one line of the
92+ // window; that it was passed at all is checked through shellOptions above.
93+ a.NewTerminal()
94+ waitOnScreen(t, a, screen, "NewTerminal", "remote shell in /")
95+ if got := a.Desktop().Active().Title(); got != "bob@machine" {
96+ t.Errorf("the window is called %q, want the machine", got)
97+ }
98+}
99+
100+func TestTheAgentMenuSaysAgentsAreNotAvailableOverSSH(t *testing.T) {
101+ a := newRemoteApp(t)
102+
103+ items := a.agentItems()
104+ if len(items) != 1 || !strings.Contains(items[0].Label, "not available over ssh") || items[0].Enabled() {
105+ t.Errorf("agent menu = %+v, want one disabled line saying why", items)
106+ }
107+}
108+
109+func TestOpeningAnAgentOverSSHExplainsWhyNot(t *testing.T) {
110+ for name, open := range map[string]func(a *App){
111+ "NewAgent": func(a *App) { a.NewAgent("anything") },
112+ "CreateAgentsFile": func(a *App) { a.CreateAgentsFile() },
113+ "AgentStatus": func(a *App) { a.AgentStatus() },
114+ } {
115+ a := newRemoteApp(t)
116+ open(a)
117+ if a.Modals() != 1 || len(a.agents) != 0 {
118+ t.Errorf("%s: Modals() = %d, agents = %d; want one message and no agent", name, a.Modals(), len(a.agents))
119+ }
120+ }
121+}
122+
123+func TestTheStatusBarNamesTheRemoteMachine(t *testing.T) {
124+ a := newRemoteApp(t)
125+ if got := a.connectionStatus(); !strings.HasPrefix(got, "⇄ bob@machine") {
126+ t.Errorf("connectionStatus() = %q, want it to start with the machine", got)
127+ }
128+ local, _ := newTestApp(t)
129+ if got := local.connectionStatus(); strings.Contains(got, "⇄") {
130+ t.Errorf("a local editor's status names a machine: %q", got)
131+ }
132+}
new file mode 100644
@@ -0,0 +1,132 @@
1+package app
2+
3+import (
4+ "os"
5+ "slices"
6+ "strings"
7+ "testing"
8+
9+ "github.com/gdamore/tcell/v2"
10+
11+ "rickub.com/turbo-editors/turbo-core/tools"
12+)
13+
14+// fakeRemote stands in for an ssh session. Its command lines run /bin/sh on
15+// this machine, and say which directory and command they were given, so a
16+// test can see that the editor asked the remote side — not a local shell — to
17+// run something, and where.
18+type fakeRemote struct{}
19+
20+func (fakeRemote) Name() string { return "bob@machine" }
21+
22+func (fakeRemote) ShellCommand(dir string) []string {
23+ return []string{"/bin/sh", "-c", `echo "remote shell in $0"; sleep 30`, dir}
24+}
25+
26+func (fakeRemote) RunCommand(dir, command string, withTerminal bool) []string {
27+ return []string{"/bin/sh", "-c", `echo "remote run [$1] in $0 terminal=$2"`, dir, command, map[bool]string{true: "yes", false: "no"}[withTerminal]}
28+}
29+
30+// newRemoteApp returns an editor told its project is on the fake machine.
31+func newRemoteApp(t *testing.T) *App {
32+ t.Helper()
33+ a, _ := newToolsApp(t, "")
34+ a.UseRemote(fakeRemote{})
35+ return a
36+}
37+
38+func TestRemoteReturnsNilForALocalEditorAndTheMachineForARemoteOne(t *testing.T) {
39+ local, _ := newTestApp(t)
40+ if local.Remote() != nil {
41+ t.Errorf("Remote() = %v for an editor never told of a machine", local.Remote())
42+ }
43+ if newRemoteApp(t).Remote() == nil {
44+ t.Error("Remote() = nil after UseRemote")
45+ }
46+}
47+
48+func TestAPopupToolRunsOnTheRemoteMachineInTheProjectDirectory(t *testing.T) {
49+ a := newRemoteApp(t)
50+ root, _ := os.Getwd()
51+
52+ runAndDrain(t, a, tools.Tool{Name: "Test", Command: "pytest -q"})
53+
54+ lines := outputLines(a)
55+ want := "remote run [pytest -q] in " + root + " terminal=no"
56+ if !slices.ContainsFunc(lines, func(line string) bool { return strings.HasSuffix(line, want) || line == want }) {
57+ t.Errorf("output = %q, want a line %q", lines, want)
58+ }
59+ if got := a.running.dialog.Title(); !strings.Contains(got, "pytest -q") {
60+ t.Errorf("the dialog is called %q, want it named after the command, not after ssh", got)
61+ }
62+}
63+
64+func TestATerminalToolRunsOnTheRemoteMachineWithATerminal(t *testing.T) {
65+ skipWithoutPTY(t)
66+ a := newRemoteApp(t)
67+ t.Cleanup(a.closeTerminals)
68+
69+ options := a.commandOptions("python3", "/srv/app")
70+ if options.Shell != "/bin/sh" || options.Dir != "" || options.Args[len(options.Args)-1] != "yes" {
71+ t.Errorf("commandOptions = %+v, want the remote's command line with a terminal, started here", options)
72+ }
73+
74+ a.RunTool(tools.Tool{Name: "REPL", Command: "python3", Output: tools.OutputTerminal})
75+ if got := a.Desktop().Active().Title(); got != "python3" {
76+ t.Errorf("the window is called %q, want the command", got)
77+ }
78+}
79+
80+func TestATerminalWindowIsAShellOnTheRemoteMachine(t *testing.T) {
81+ skipWithoutPTY(t)
82+ a := newRemoteApp(t)
83+ t.Cleanup(a.closeTerminals)
84+ screen := a.screen.(tcell.SimulationScreen)
85+
86+ options, name := a.shellOptions("/home/bob/app")
87+ if name != "bob@machine" || options.Dir != "" || options.Args[len(options.Args)-1] != "/home/bob/app" {
88+ t.Errorf("shellOptions = %+v, %q", options, name)
89+ }
90+
91+ // The directory is the working directory, too long for one line of the
92+ // window; that it was passed at all is checked through shellOptions above.
93+ a.NewTerminal()
94+ waitOnScreen(t, a, screen, "NewTerminal", "remote shell in /")
95+ if got := a.Desktop().Active().Title(); got != "bob@machine" {
96+ t.Errorf("the window is called %q, want the machine", got)
97+ }
98+}
99+
100+func TestTheAgentMenuSaysAgentsAreNotAvailableOverSSH(t *testing.T) {
101+ a := newRemoteApp(t)
102+
103+ items := a.agentItems()
104+ if len(items) != 1 || !strings.Contains(items[0].Label, "not available over ssh") || items[0].Enabled() {
105+ t.Errorf("agent menu = %+v, want one disabled line saying why", items)
106+ }
107+}
108+
109+func TestOpeningAnAgentOverSSHExplainsWhyNot(t *testing.T) {
110+ for name, open := range map[string]func(a *App){
111+ "NewAgent": func(a *App) { a.NewAgent("anything") },
112+ "CreateAgentsFile": func(a *App) { a.CreateAgentsFile() },
113+ "AgentStatus": func(a *App) { a.AgentStatus() },
114+ } {
115+ a := newRemoteApp(t)
116+ open(a)
117+ if a.Modals() != 1 || len(a.agents) != 0 {
118+ t.Errorf("%s: Modals() = %d, agents = %d; want one message and no agent", name, a.Modals(), len(a.agents))
119+ }
120+ }
121+}
122+
123+func TestTheStatusBarNamesTheRemoteMachine(t *testing.T) {
124+ a := newRemoteApp(t)
125+ if got := a.connectionStatus(); !strings.HasPrefix(got, "⇄ bob@machine") {
126+ t.Errorf("connectionStatus() = %q, want it to start with the machine", got)
127+ }
128+ local, _ := newTestApp(t)
129+ if got := local.connectionStatus(); strings.Contains(got, "⇄") {
130+ t.Errorf("a local editor's status names a machine: %q", got)
131+ }
132+}
modified app/root.go +4 -4
@@ -4,9 +4,9 @@
44 package app
55
66 import (
7- "os"
87 "path/filepath"
98
9+ "rickub.com/turbo-editors/turbo-core/fsys"
1010 "rickub.com/turbo-editors/turbo-core/profile"
1111 )
1212
@@ -29,12 +29,12 @@ import (
2929 // root := app.ProjectRoot(golang.Profile(), []string{"internal/app/app.go"})
3030 // editor.StartLanguageServer(ctx, root)
3131 func ProjectRoot(p profile.Profile, files []string) string {
32- start, err := os.Getwd()
32+ start, err := fsys.Getwd()
3333 if err != nil {
3434 start = "."
3535 }
3636 if len(files) > 0 {
37- if absolute, err := filepath.Abs(files[0]); err == nil {
37+ if absolute, err := fsys.Abs(files[0]); err == nil {
3838 start = filepath.Dir(absolute)
3939 }
4040 }
@@ -68,7 +68,7 @@ func markedRoot(start string, markers []string) string {
6868 // holdsAny reports whether a directory holds one of the marker files.
6969 func holdsAny(directory string, markers []string) bool {
7070 for _, marker := range markers {
71- if _, err := os.Stat(filepath.Join(directory, marker)); err == nil {
71+ if _, err := fsys.Stat(filepath.Join(directory, marker)); err == nil {
7272 return true
7373 }
7474 }
@@ -4,9 +4,9 @@
4 package app4 package app
5 5
6 import (6 import (
7- "os"
8 "path/filepath"7 "path/filepath"
9 8
9+ "rickub.com/turbo-editors/turbo-core/fsys"
10 "rickub.com/turbo-editors/turbo-core/profile"10 "rickub.com/turbo-editors/turbo-core/profile"
11 )11 )
12 12
@@ -29,12 +29,12 @@ import (
29 // root := app.ProjectRoot(golang.Profile(), []string{"internal/app/app.go"})29 // root := app.ProjectRoot(golang.Profile(), []string{"internal/app/app.go"})
30 // editor.StartLanguageServer(ctx, root)30 // editor.StartLanguageServer(ctx, root)
31 func ProjectRoot(p profile.Profile, files []string) string {31 func ProjectRoot(p profile.Profile, files []string) string {
32- start, err := os.Getwd()32+ start, err := fsys.Getwd()
33 if err != nil {33 if err != nil {
34 start = "."34 start = "."
35 }35 }
36 if len(files) > 0 {36 if len(files) > 0 {
37- if absolute, err := filepath.Abs(files[0]); err == nil {37+ if absolute, err := fsys.Abs(files[0]); err == nil {
38 start = filepath.Dir(absolute)38 start = filepath.Dir(absolute)
39 }39 }
40 }40 }
@@ -68,7 +68,7 @@ func markedRoot(start string, markers []string) string {
68 // holdsAny reports whether a directory holds one of the marker files.68 // holdsAny reports whether a directory holds one of the marker files.
69 func holdsAny(directory string, markers []string) bool {69 func holdsAny(directory string, markers []string) bool {
70 for _, marker := range markers {70 for _, marker := range markers {
71- if _, err := os.Stat(filepath.Join(directory, marker)); err == nil {71+ if _, err := fsys.Stat(filepath.Join(directory, marker)); err == nil {
72 return true72 return true
73 }73 }
74 }74 }
modified app/snippets.go +2 -3
@@ -4,8 +4,7 @@
44 package app
55
66 import (
7- "os"
8-
7+ "rickub.com/turbo-editors/turbo-core/fsys"
98 "rickub.com/turbo-editors/turbo-core/snippets"
109 "rickub.com/turbo-editors/turbo-core/syntax"
1110 "rickub.com/turbo-editors/turbo-core/ui"
@@ -87,7 +86,7 @@ func snippetSubmenu(a *App, list []snippets.Snippet) []*ui.MenuItem {
8786
8887 // loadSnippets reads the project's snippets and the user's own.
8988 func (a *App) loadSnippets() (snippets.List, error) {
90- working, err := os.Getwd()
89+ working, err := fsys.Getwd()
9190 if err != nil {
9291 return snippets.List{}, err
9392 }
@@ -4,8 +4,7 @@
4 package app4 package app
5 5
6 import (6 import (
7- "os"7+ "rickub.com/turbo-editors/turbo-core/fsys"
8-
9 "rickub.com/turbo-editors/turbo-core/snippets"8 "rickub.com/turbo-editors/turbo-core/snippets"
10 "rickub.com/turbo-editors/turbo-core/syntax"9 "rickub.com/turbo-editors/turbo-core/syntax"
11 "rickub.com/turbo-editors/turbo-core/ui"10 "rickub.com/turbo-editors/turbo-core/ui"
@@ -87,7 +86,7 @@ func snippetSubmenu(a *App, list []snippets.Snippet) []*ui.MenuItem {
87 86
88 // loadSnippets reads the project's snippets and the user's own.87 // loadSnippets reads the project's snippets and the user's own.
89 func (a *App) loadSnippets() (snippets.List, error) {88 func (a *App) loadSnippets() (snippets.List, error) {
90- working, err := os.Getwd()89+ working, err := fsys.Getwd()
91 if err != nil {90 if err != nil {
92 return snippets.List{}, err91 return snippets.List{}, err
93 }92 }
modified app/terminals.go +32 -4
@@ -5,11 +5,12 @@ package app
55
66 import (
77 "errors"
8- "os"
98 "path/filepath"
109
1110 "rickub.com/turbo-editors/turbo-core/editor"
11+ "rickub.com/turbo-editors/turbo-core/fsys"
1212 "rickub.com/turbo-editors/turbo-core/terminal"
13+ "rickub.com/turbo-editors/turbo-core/tools"
1314 "rickub.com/turbo-editors/turbo-core/ui"
1415 )
1516
@@ -25,8 +26,10 @@ func (a *App) NewTerminal() {
2526 // The shell writes from a goroutine of its own, so neither callback may
2627 // touch the desktop: they only ask the event loop to come round again. They
2728 // are options rather than fields because NewView starts that goroutine.
29+ options, name := a.shellOptions(a.terminalDirectory())
2830 view, err := terminal.NewView(terminal.ViewOptions{
29- Options: terminal.Options{Dir: a.terminalDirectory()},
31+ Options: options,
32+ Name: name,
3033 OnChange: a.wake,
3134 OnExit: a.wake,
3235 })
@@ -60,16 +63,41 @@ func (a *App) reportTerminalFailure(err error) {
6063 // being edited, or the editor's own working directory.
6164 func (a *App) terminalDirectory() string {
6265 if view := a.activeView(); view != nil && view.Buffer().Path() != "" {
63- if absolute, err := filepath.Abs(view.Buffer().Path()); err == nil {
66+ if absolute, err := fsys.Abs(view.Buffer().Path()); err == nil {
6467 return filepath.Dir(absolute)
6568 }
6669 }
67- if working, err := os.Getwd(); err == nil {
70+ if working, err := fsys.Getwd(); err == nil {
6871 return working
6972 }
7073 return ""
7174 }
7275
76+// shellOptions says how to start an interactive shell in dir, and what to
77+// call its window until the shell names itself.
78+//
79+// Locally that is the user's own shell, started in dir. Over ssh it is ssh,
80+// started wherever this process is — dir is a directory on the other machine,
81+// and it is the remote shell that changes into it.
82+func (a *App) shellOptions(dir string) (terminal.Options, string) {
83+ if a.remote == nil {
84+ return terminal.Options{Dir: dir}, ""
85+ }
86+ argv := a.remote.ShellCommand(dir)
87+ return terminal.Options{Shell: argv[0], Args: argv[1:]}, a.remote.Name()
88+}
89+
90+// commandOptions says how to run one command line in dir in a terminal
91+// window: the shell tools uses, locally; ssh with a terminal on the far side,
92+// remotely.
93+func (a *App) commandOptions(command, dir string) terminal.Options {
94+ if a.remote == nil {
95+ return terminal.Options{Shell: tools.Shell(), Args: tools.ShellArgs(command), Dir: dir}
96+ }
97+ argv := a.remote.RunCommand(dir, command, true)
98+ return terminal.Options{Shell: argv[0], Args: argv[1:]}
99+}
100+
73101 // closeTerminal ends a shell and takes its window away.
74102 //
75103 // A terminal is not asked about unsaved work: there is none. What it holds is
@@ -5,11 +5,12 @@ package app
5 5
6 import (6 import (
7 "errors"7 "errors"
8- "os"
9 "path/filepath"8 "path/filepath"
10 9
11 "rickub.com/turbo-editors/turbo-core/editor"10 "rickub.com/turbo-editors/turbo-core/editor"
11+ "rickub.com/turbo-editors/turbo-core/fsys"
12 "rickub.com/turbo-editors/turbo-core/terminal"12 "rickub.com/turbo-editors/turbo-core/terminal"
13+ "rickub.com/turbo-editors/turbo-core/tools"
13 "rickub.com/turbo-editors/turbo-core/ui"14 "rickub.com/turbo-editors/turbo-core/ui"
14 )15 )
15 16
@@ -25,8 +26,10 @@ func (a *App) NewTerminal() {
25 // The shell writes from a goroutine of its own, so neither callback may26 // The shell writes from a goroutine of its own, so neither callback may
26 // touch the desktop: they only ask the event loop to come round again. They27 // touch the desktop: they only ask the event loop to come round again. They
27 // are options rather than fields because NewView starts that goroutine.28 // are options rather than fields because NewView starts that goroutine.
29+ options, name := a.shellOptions(a.terminalDirectory())
28 view, err := terminal.NewView(terminal.ViewOptions{30 view, err := terminal.NewView(terminal.ViewOptions{
29- Options: terminal.Options{Dir: a.terminalDirectory()},31+ Options: options,
32+ Name: name,
30 OnChange: a.wake,33 OnChange: a.wake,
31 OnExit: a.wake,34 OnExit: a.wake,
32 })35 })
@@ -60,16 +63,41 @@ func (a *App) reportTerminalFailure(err error) {
60 // being edited, or the editor's own working directory.63 // being edited, or the editor's own working directory.
61 func (a *App) terminalDirectory() string {64 func (a *App) terminalDirectory() string {
62 if view := a.activeView(); view != nil && view.Buffer().Path() != "" {65 if view := a.activeView(); view != nil && view.Buffer().Path() != "" {
63- if absolute, err := filepath.Abs(view.Buffer().Path()); err == nil {66+ if absolute, err := fsys.Abs(view.Buffer().Path()); err == nil {
64 return filepath.Dir(absolute)67 return filepath.Dir(absolute)
65 }68 }
66 }69 }
67- if working, err := os.Getwd(); err == nil {70+ if working, err := fsys.Getwd(); err == nil {
68 return working71 return working
69 }72 }
70 return ""73 return ""
71 }74 }
72 75
76+// shellOptions says how to start an interactive shell in dir, and what to
77+// call its window until the shell names itself.
78+//
79+// Locally that is the user's own shell, started in dir. Over ssh it is ssh,
80+// started wherever this process is — dir is a directory on the other machine,
81+// and it is the remote shell that changes into it.
82+func (a *App) shellOptions(dir string) (terminal.Options, string) {
83+ if a.remote == nil {
84+ return terminal.Options{Dir: dir}, ""
85+ }
86+ argv := a.remote.ShellCommand(dir)
87+ return terminal.Options{Shell: argv[0], Args: argv[1:]}, a.remote.Name()
88+}
89+
90+// commandOptions says how to run one command line in dir in a terminal
91+// window: the shell tools uses, locally; ssh with a terminal on the far side,
92+// remotely.
93+func (a *App) commandOptions(command, dir string) terminal.Options {
94+ if a.remote == nil {
95+ return terminal.Options{Shell: tools.Shell(), Args: tools.ShellArgs(command), Dir: dir}
96+ }
97+ argv := a.remote.RunCommand(dir, command, true)
98+ return terminal.Options{Shell: argv[0], Args: argv[1:]}
99+}
100+
73 // closeTerminal ends a shell and takes its window away.101 // closeTerminal ends a shell and takes its window away.
74 //102 //
75 // A terminal is not asked about unsaved work: there is none. What it holds is103 // A terminal is not asked about unsaved work: there is none. What it holds is
modified app/toolchain.go +15 -10
@@ -7,11 +7,11 @@ package app
77
88 import (
99 "fmt"
10- "os"
1110 "strconv"
1211 "strings"
1312
1413 "rickub.com/turbo-editors/turbo-core/buffer"
14+ "rickub.com/turbo-editors/turbo-core/fsys"
1515
1616 "rickub.com/turbo-editors/turbo-core/terminal"
1717 "rickub.com/turbo-editors/turbo-core/tools"
@@ -81,7 +81,7 @@ func (a *App) commandItems(menu string) []*ui.MenuItem {
8181
8282 // loadTools reads the project's tools file.
8383 func (a *App) loadTools() (tools.List, error) {
84- working, err := os.Getwd()
84+ working, err := fsys.Getwd()
8585 if err != nil {
8686 return tools.List{}, err
8787 }
@@ -146,7 +146,7 @@ func (a *App) rememberToolValues(tool string, values map[string]string) {
146146 // runFilledTool runs a command whose placeholders, if it had any, are already
147147 // substituted, and shows its output where the tool asked for.
148148 func (a *App) runFilledTool(tool tools.Tool, command string) {
149- working, err := os.Getwd()
149+ working, err := fsys.Getwd()
150150 if err != nil {
151151 a.ShowMessage(a.toolsMenuName(), "Cannot tell which directory this is:\n"+err.Error())
152152 return
@@ -168,12 +168,8 @@ func (a *App) runFilledTool(tool tools.Tool, command string) {
168168 func (a *App) runInTerminal(command, dir string) {
169169 view, err := terminal.NewView(terminal.ViewOptions{
170170 // The same shell the popup uses, told to run one command line and
171- // exit; tools knows which shell that is on this platform.
172- Options: terminal.Options{
173- Shell: tools.Shell(),
174- Args: tools.ShellArgs(command),
175- Dir: dir,
176- },
171+ // exit — on this machine, or on the one being edited.
172+ Options: a.commandOptions(command, dir),
177173 Name: command,
178174 OnChange: a.wake,
179175 // A command that has finished may have rewritten the files on disk,
@@ -204,7 +200,7 @@ func (a *App) runInTerminal(command, dir string) {
204200 // typed at that moment, and watching a build's output arrive is most of what
205201 // makes waiting for it bearable.
206202 func (a *App) runCaptured(tool tools.Tool, command, dir string) {
207- run, err := tools.Start(command, dir, a.wake)
203+ run, err := a.startTool(command, dir)
208204 if err != nil {
209205 a.ShowMessage(a.toolsMenuName(), err.Error())
210206 return
@@ -219,6 +215,15 @@ func (a *App) runCaptured(tool tools.Tool, command, dir string) {
219215 a.pushModal(dialog, func(ui.Result) { a.finishRun() })
220216 }
221217
218+// startTool starts a command line with its output collected: with /bin/sh
219+// (or cmd.exe) here, or with /bin/sh on the machine being edited.
220+func (a *App) startTool(command, dir string) (*tools.Run, error) {
221+ if a.remote == nil {
222+ return tools.Start(command, dir, a.wake)
223+ }
224+ return tools.StartProgram(command, a.remote.RunCommand(dir, command, false), a.wake)
225+}
226+
222227 // toolRun is the command whose output is showing, and where it goes.
223228 type toolRun struct {
224229 run *tools.Run
@@ -7,11 +7,11 @@ package app
7 7
8 import (8 import (
9 "fmt"9 "fmt"
10- "os"
11 "strconv"10 "strconv"
12 "strings"11 "strings"
13 12
14 "rickub.com/turbo-editors/turbo-core/buffer"13 "rickub.com/turbo-editors/turbo-core/buffer"
14+ "rickub.com/turbo-editors/turbo-core/fsys"
15 15
16 "rickub.com/turbo-editors/turbo-core/terminal"16 "rickub.com/turbo-editors/turbo-core/terminal"
17 "rickub.com/turbo-editors/turbo-core/tools"17 "rickub.com/turbo-editors/turbo-core/tools"
@@ -81,7 +81,7 @@ func (a *App) commandItems(menu string) []*ui.MenuItem {
81 81
82 // loadTools reads the project's tools file.82 // loadTools reads the project's tools file.
83 func (a *App) loadTools() (tools.List, error) {83 func (a *App) loadTools() (tools.List, error) {
84- working, err := os.Getwd()84+ working, err := fsys.Getwd()
85 if err != nil {85 if err != nil {
86 return tools.List{}, err86 return tools.List{}, err
87 }87 }
@@ -146,7 +146,7 @@ func (a *App) rememberToolValues(tool string, values map[string]string) {
146 // runFilledTool runs a command whose placeholders, if it had any, are already146 // runFilledTool runs a command whose placeholders, if it had any, are already
147 // substituted, and shows its output where the tool asked for.147 // substituted, and shows its output where the tool asked for.
148 func (a *App) runFilledTool(tool tools.Tool, command string) {148 func (a *App) runFilledTool(tool tools.Tool, command string) {
149- working, err := os.Getwd()149+ working, err := fsys.Getwd()
150 if err != nil {150 if err != nil {
151 a.ShowMessage(a.toolsMenuName(), "Cannot tell which directory this is:\n"+err.Error())151 a.ShowMessage(a.toolsMenuName(), "Cannot tell which directory this is:\n"+err.Error())
152 return152 return
@@ -168,12 +168,8 @@ func (a *App) runFilledTool(tool tools.Tool, command string) {
168 func (a *App) runInTerminal(command, dir string) {168 func (a *App) runInTerminal(command, dir string) {
169 view, err := terminal.NewView(terminal.ViewOptions{169 view, err := terminal.NewView(terminal.ViewOptions{
170 // The same shell the popup uses, told to run one command line and170 // The same shell the popup uses, told to run one command line and
171- // exit; tools knows which shell that is on this platform.171+ // exit — on this machine, or on the one being edited.
172- Options: terminal.Options{172+ Options: a.commandOptions(command, dir),
173- Shell: tools.Shell(),
174- Args: tools.ShellArgs(command),
175- Dir: dir,
176- },
177 Name: command,173 Name: command,
178 OnChange: a.wake,174 OnChange: a.wake,
179 // A command that has finished may have rewritten the files on disk,175 // A command that has finished may have rewritten the files on disk,
@@ -204,7 +200,7 @@ func (a *App) runInTerminal(command, dir string) {
204 // typed at that moment, and watching a build's output arrive is most of what200 // typed at that moment, and watching a build's output arrive is most of what
205 // makes waiting for it bearable.201 // makes waiting for it bearable.
206 func (a *App) runCaptured(tool tools.Tool, command, dir string) {202 func (a *App) runCaptured(tool tools.Tool, command, dir string) {
207- run, err := tools.Start(command, dir, a.wake)203+ run, err := a.startTool(command, dir)
208 if err != nil {204 if err != nil {
209 a.ShowMessage(a.toolsMenuName(), err.Error())205 a.ShowMessage(a.toolsMenuName(), err.Error())
210 return206 return
@@ -219,6 +215,15 @@ func (a *App) runCaptured(tool tools.Tool, command, dir string) {
219 a.pushModal(dialog, func(ui.Result) { a.finishRun() })215 a.pushModal(dialog, func(ui.Result) { a.finishRun() })
220 }216 }
221 217
218+// startTool starts a command line with its output collected: with /bin/sh
219+// (or cmd.exe) here, or with /bin/sh on the machine being edited.
220+func (a *App) startTool(command, dir string) (*tools.Run, error) {
221+ if a.remote == nil {
222+ return tools.Start(command, dir, a.wake)
223+ }
224+ return tools.StartProgram(command, a.remote.RunCommand(dir, command, false), a.wake)
225+}
226+
222 // toolRun is the command whose output is showing, and where it goes.227 // toolRun is the command whose output is showing, and where it goes.
223 type toolRun struct {228 type toolRun struct {
224 run *tools.Run229 run *tools.Run
modified app/toolmenus.go +3 -3
@@ -4,10 +4,10 @@
44 package app
55
66 import (
7- "os"
87 "time"
98 "unicode"
109
10+ "rickub.com/turbo-editors/turbo-core/fsys"
1111 "rickub.com/turbo-editors/turbo-core/tools"
1212 "rickub.com/turbo-editors/turbo-core/ui"
1313 )
@@ -129,7 +129,7 @@ type fileStamp struct {
129129 // stampOf describes a file. A file that is not there stamps as the zero value,
130130 // so appearing and disappearing both register as changes.
131131 func stampOf(path string) fileStamp {
132- info, err := os.Stat(path)
132+ info, err := fsys.Stat(path)
133133 if err != nil {
134134 return fileStamp{}
135135 }
@@ -138,7 +138,7 @@ func stampOf(path string) fileStamp {
138138
139139 // toolsFileStamp describes the project's tools file.
140140 func (a *App) toolsFileStamp() fileStamp {
141- working, err := os.Getwd()
141+ working, err := fsys.Getwd()
142142 if err != nil {
143143 return fileStamp{}
144144 }
@@ -4,10 +4,10 @@
4 package app4 package app
5 5
6 import (6 import (
7- "os"
8 "time"7 "time"
9 "unicode"8 "unicode"
10 9
10+ "rickub.com/turbo-editors/turbo-core/fsys"
11 "rickub.com/turbo-editors/turbo-core/tools"11 "rickub.com/turbo-editors/turbo-core/tools"
12 "rickub.com/turbo-editors/turbo-core/ui"12 "rickub.com/turbo-editors/turbo-core/ui"
13 )13 )
@@ -129,7 +129,7 @@ type fileStamp struct {
129 // stampOf describes a file. A file that is not there stamps as the zero value,129 // stampOf describes a file. A file that is not there stamps as the zero value,
130 // so appearing and disappearing both register as changes.130 // so appearing and disappearing both register as changes.
131 func stampOf(path string) fileStamp {131 func stampOf(path string) fileStamp {
132- info, err := os.Stat(path)132+ info, err := fsys.Stat(path)
133 if err != nil {133 if err != nil {
134 return fileStamp{}134 return fileStamp{}
135 }135 }
@@ -138,7 +138,7 @@ func stampOf(path string) fileStamp {
138 138
139 // toolsFileStamp describes the project's tools file.139 // toolsFileStamp describes the project's tools file.
140 func (a *App) toolsFileStamp() fileStamp {140 func (a *App) toolsFileStamp() fileStamp {
141- working, err := os.Getwd()141+ working, err := fsys.Getwd()
142 if err != nil {142 if err != nil {
143 return fileStamp{}143 return fileStamp{}
144 }144 }
modified app/tree.go +2 -3
@@ -4,9 +4,8 @@
44 package app
55
66 import (
7- "os"
8-
97 "rickub.com/turbo-editors/turbo-core/filetree"
8+ "rickub.com/turbo-editors/turbo-core/fsys"
109 "rickub.com/turbo-editors/turbo-core/ui"
1110 )
1211
@@ -26,7 +25,7 @@ func (a *App) ProjectTree() {
2625 return
2726 }
2827
29- root, err := os.Getwd()
28+ root, err := fsys.Getwd()
3029 if err != nil {
3130 a.ShowMessage("Project tree", "Cannot tell which directory this is:\n"+err.Error())
3231 return
@@ -4,9 +4,8 @@
4 package app4 package app
5 5
6 import (6 import (
7- "os"
8-
9 "rickub.com/turbo-editors/turbo-core/filetree"7 "rickub.com/turbo-editors/turbo-core/filetree"
8+ "rickub.com/turbo-editors/turbo-core/fsys"
10 "rickub.com/turbo-editors/turbo-core/ui"9 "rickub.com/turbo-editors/turbo-core/ui"
11 )10 )
12 11
@@ -26,7 +25,7 @@ func (a *App) ProjectTree() {
26 return25 return
27 }26 }
28 27
29- root, err := os.Getwd()28+ root, err := fsys.Getwd()
30 if err != nil {29 if err != nil {
31 a.ShowMessage("Project tree", "Cannot tell which directory this is:\n"+err.Error())30 a.ShowMessage("Project tree", "Cannot tell which directory this is:\n"+err.Error())
32 return31 return
modified buffer/README.md +2 -0
@@ -4,6 +4,8 @@ The text of one edited file: its lines, the cursor, the selection and the undo h
44
55 This package knows nothing about terminals, rendering or syntax colouring. Everything here is plain data manipulation, which is why it is fully testable on its own and why every other package can depend on it without dragging a terminal in.
66
7+Opening, reloading and saving go through `fsys`, so under `-ssh` the file is the remote machine's. A save still goes to a temporary file beside the target and is renamed over it, keeping the mode of the file it replaces.
8+
79 ## Model
810
911 - Text is a slice of lines, each a slice of runes. A buffer always holds **at least one line**, so an empty buffer is one empty line rather than none.
@@ -4,6 +4,8 @@ The text of one edited file: its lines, the cursor, the selection and the undo h
4 4
5 This package knows nothing about terminals, rendering or syntax colouring. Everything here is plain data manipulation, which is why it is fully testable on its own and why every other package can depend on it without dragging a terminal in.5 This package knows nothing about terminals, rendering or syntax colouring. Everything here is plain data manipulation, which is why it is fully testable on its own and why every other package can depend on it without dragging a terminal in.
6 6
7+Opening, reloading and saving go through `fsys`, so under `-ssh` the file is the remote machine's. A save still goes to a temporary file beside the target and is renamed over it, keeping the mode of the file it replaces.
8+
7 ## Model9 ## Model
8 10
9 - Text is a slice of lines, each a slice of runes. A buffer always holds **at least one line**, so an empty buffer is one empty line rather than none.11 - Text is a slice of lines, each a slice of runes. A buffer always holds **at least one line**, so an empty buffer is one empty line rather than none.
modified buffer/file.go +14 -44
@@ -4,7 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "os"
7- "path/filepath"
7+
8+ "rickub.com/turbo-editors/turbo-core/fsys"
89 )
910
1011 // ErrNoPath is returned by Save when the buffer has never been given a file
@@ -31,7 +32,7 @@ func Open(path string) (*Buffer, error) {
3132 b := New()
3233 b.path = path
3334
34- data, err := os.ReadFile(path)
35+ data, err := fsys.ReadFile(path)
3536 if errors.Is(err, os.ErrNotExist) {
3637 b.clearHistory()
3738 return b, nil
@@ -73,7 +74,7 @@ func (b *Buffer) Reload() (bool, error) {
7374 return false, ErrModified
7475 }
7576
76- data, err := os.ReadFile(b.path)
77+ data, err := fsys.ReadFile(b.path)
7778 if err != nil {
7879 return false, fmt.Errorf("buffer: reload %s: %w", b.path, err)
7980 }
@@ -103,7 +104,8 @@ func (b *Buffer) Save() error {
103104 //
104105 // The write goes to a temporary file in the same directory which is then
105106 // renamed over the target, so an interrupted save cannot leave a half-written
106-// source file behind.
107+// source file behind. Like every read and write here it goes through fsys, so
108+// under -ssh the file is the remote one.
107109 func (b *Buffer) SaveAs(path string) error {
108110 if err := writeFileAtomically(path, []byte(b.Text())); err != nil {
109111 return err
@@ -113,56 +115,24 @@ func (b *Buffer) SaveAs(path string) error {
113115 return nil
114116 }
115117
116-// writeFileAtomically writes data to path via a temporary file and a rename,
117-// so that readers of path only ever see the old content or the new one.
118+// writeFileAtomically writes data to path so that readers of path only ever
119+// see the old content or the new one, keeping the permissions of the file it
120+// replaces. It goes through fsys, so the file may be on another machine.
118121 func writeFileAtomically(path string, data []byte) error {
119- dir := filepath.Dir(path)
120-
121- tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
122- if err != nil {
123- return fmt.Errorf("buffer: save %s: %w", path, err)
124- }
125- tmpName := tmp.Name()
126-
127- // CreateTemp makes the file readable by its owner only; the rename would
128- // otherwise silently tighten the permissions of an existing source file.
129- if err := os.Chmod(tmpName, modeOf(path)); err != nil {
130- tmp.Close()
131- os.Remove(tmpName)
132- return fmt.Errorf("buffer: save %s: %w", path, err)
133- }
134-
135- // From here on every failure must remove the temporary file, or a failed
136- // save would litter the user's source directory.
137- if err := writeAndClose(tmp, data); err != nil {
138- os.Remove(tmpName)
139- return fmt.Errorf("buffer: save %s: %w", path, err)
140- }
141-
142- if err := os.Rename(tmpName, path); err != nil {
143- os.Remove(tmpName)
122+ if err := fsys.WriteFileAtomic(path, data, modeOf(path)); err != nil {
144123 return fmt.Errorf("buffer: save %s: %w", path, err)
145124 }
146125 return nil
147126 }
148127
149-// writeAndClose writes data to f and closes it, reporting the first failure.
150-func writeAndClose(f *os.File, data []byte) error {
151- if _, err := f.Write(data); err != nil {
152- f.Close()
153- return err
154- }
155- return f.Close()
156-}
157-
158-// defaultFileMode is what a source file created by the editor gets, before the
159-// process umask is applied.
128+// defaultFileMode is what a source file created by the editor gets.
160129 const defaultFileMode = 0o644
161130
162131 // modeOf returns the permissions of an existing file, or defaultFileMode when
163-// the file is new or cannot be inspected.
132+// the file is new or cannot be inspected. Without it the temporary file's
133+// permissions would silently replace those of the source file being saved.
164134 func modeOf(path string) os.FileMode {
165- info, err := os.Stat(path)
135+ info, err := fsys.Stat(path)
166136 if err != nil {
167137 return defaultFileMode
168138 }
@@ -4,7 +4,8 @@ import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6 "os"6 "os"
7- "path/filepath"7+
8+ "rickub.com/turbo-editors/turbo-core/fsys"
8 )9 )
9 10
10 // ErrNoPath is returned by Save when the buffer has never been given a file11 // ErrNoPath is returned by Save when the buffer has never been given a file
@@ -31,7 +32,7 @@ func Open(path string) (*Buffer, error) {
31 b := New()32 b := New()
32 b.path = path33 b.path = path
33 34
34- data, err := os.ReadFile(path)35+ data, err := fsys.ReadFile(path)
35 if errors.Is(err, os.ErrNotExist) {36 if errors.Is(err, os.ErrNotExist) {
36 b.clearHistory()37 b.clearHistory()
37 return b, nil38 return b, nil
@@ -73,7 +74,7 @@ func (b *Buffer) Reload() (bool, error) {
73 return false, ErrModified74 return false, ErrModified
74 }75 }
75 76
76- data, err := os.ReadFile(b.path)77+ data, err := fsys.ReadFile(b.path)
77 if err != nil {78 if err != nil {
78 return false, fmt.Errorf("buffer: reload %s: %w", b.path, err)79 return false, fmt.Errorf("buffer: reload %s: %w", b.path, err)
79 }80 }
@@ -103,7 +104,8 @@ func (b *Buffer) Save() error {
103 //104 //
104 // The write goes to a temporary file in the same directory which is then105 // The write goes to a temporary file in the same directory which is then
105 // renamed over the target, so an interrupted save cannot leave a half-written106 // renamed over the target, so an interrupted save cannot leave a half-written
106-// source file behind.107+// source file behind. Like every read and write here it goes through fsys, so
108+// under -ssh the file is the remote one.
107 func (b *Buffer) SaveAs(path string) error {109 func (b *Buffer) SaveAs(path string) error {
108 if err := writeFileAtomically(path, []byte(b.Text())); err != nil {110 if err := writeFileAtomically(path, []byte(b.Text())); err != nil {
109 return err111 return err
@@ -113,56 +115,24 @@ func (b *Buffer) SaveAs(path string) error {
113 return nil115 return nil
114 }116 }
115 117
116-// writeFileAtomically writes data to path via a temporary file and a rename,118+// writeFileAtomically writes data to path so that readers of path only ever
117-// so that readers of path only ever see the old content or the new one.119+// see the old content or the new one, keeping the permissions of the file it
120+// replaces. It goes through fsys, so the file may be on another machine.
118 func writeFileAtomically(path string, data []byte) error {121 func writeFileAtomically(path string, data []byte) error {
119- dir := filepath.Dir(path)122+ if err := fsys.WriteFileAtomic(path, data, modeOf(path)); err != nil {
120-
121- tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
122- if err != nil {
123- return fmt.Errorf("buffer: save %s: %w", path, err)
124- }
125- tmpName := tmp.Name()
126-
127- // CreateTemp makes the file readable by its owner only; the rename would
128- // otherwise silently tighten the permissions of an existing source file.
129- if err := os.Chmod(tmpName, modeOf(path)); err != nil {
130- tmp.Close()
131- os.Remove(tmpName)
132- return fmt.Errorf("buffer: save %s: %w", path, err)
133- }
134-
135- // From here on every failure must remove the temporary file, or a failed
136- // save would litter the user's source directory.
137- if err := writeAndClose(tmp, data); err != nil {
138- os.Remove(tmpName)
139- return fmt.Errorf("buffer: save %s: %w", path, err)
140- }
141-
142- if err := os.Rename(tmpName, path); err != nil {
143- os.Remove(tmpName)
144 return fmt.Errorf("buffer: save %s: %w", path, err)123 return fmt.Errorf("buffer: save %s: %w", path, err)
145 }124 }
146 return nil125 return nil
147 }126 }
148 127
149-// writeAndClose writes data to f and closes it, reporting the first failure.128+// defaultFileMode is what a source file created by the editor gets.
150-func writeAndClose(f *os.File, data []byte) error {
151- if _, err := f.Write(data); err != nil {
152- f.Close()
153- return err
154- }
155- return f.Close()
156-}
157-
158-// defaultFileMode is what a source file created by the editor gets, before the
159-// process umask is applied.
160 const defaultFileMode = 0o644129 const defaultFileMode = 0o644
161 130
162 // modeOf returns the permissions of an existing file, or defaultFileMode when131 // modeOf returns the permissions of an existing file, or defaultFileMode when
163-// the file is new or cannot be inspected.132+// the file is new or cannot be inspected. Without it the temporary file's
133+// permissions would silently replace those of the source file being saved.
164 func modeOf(path string) os.FileMode {134 func modeOf(path string) os.FileMode {
165- info, err := os.Stat(path)135+ info, err := fsys.Stat(path)
166 if err != nil {136 if err != nil {
167 return defaultFileMode137 return defaultFileMode
168 }138 }
added buffer/fsys_test.go +81 -0
new file mode 100644
@@ -0,0 +1,81 @@
1+package buffer
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+// useMemory makes the buffer read and write a file system that exists only in
11+// memory, so a file found there proves the buffer went through fsys.
12+func useMemory(t *testing.T) *fsystest.Memory {
13+ t.Helper()
14+ memory := fsystest.New("/remote/project")
15+ previous := fsys.Use(memory)
16+ t.Cleanup(func() { fsys.Use(previous) })
17+ return memory
18+}
19+
20+func TestOpenAndSaveGoThroughTheCurrentFileSystem(t *testing.T) {
21+ memory := useMemory(t)
22+ memory.AddFile("/remote/project/app.py", "x = 1\n")
23+
24+ b, err := Open("/remote/project/app.py")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if b.Text() != "x = 1\n" {
29+ t.Fatalf("Text() = %q", b.Text())
30+ }
31+ b.SetText("x = 2\n")
32+ if err := b.Save(); err != nil {
33+ t.Fatal(err)
34+ }
35+ if got, _ := memory.File("/remote/project/app.py"); got != "x = 2\n" {
36+ t.Errorf("saved file = %q", got)
37+ }
38+}
39+
40+func TestSavingKeepsTheModeOfTheFileItReplaces(t *testing.T) {
41+ memory := useMemory(t)
42+ if err := memory.WriteFileAtomic("/remote/project/run.sh", []byte("echo\n"), 0o755); err != nil {
43+ t.Fatal(err)
44+ }
45+
46+ b, _ := Open("/remote/project/run.sh")
47+ b.SetText("echo hi\n")
48+ if err := b.Save(); err != nil {
49+ t.Fatal(err)
50+ }
51+ if mode := memory.Mode("/remote/project/run.sh"); mode != 0o755 {
52+ t.Errorf("mode after save = %v, want 0755", mode)
53+ }
54+}
55+
56+func TestANewFileIsSavedWithTheDefaultMode(t *testing.T) {
57+ memory := useMemory(t)
58+ b, err := Open("/remote/project/new.py")
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ b.SetText("pass\n")
63+ if err := b.Save(); err != nil {
64+ t.Fatal(err)
65+ }
66+ if mode := memory.Mode("/remote/project/new.py"); mode != defaultFileMode {
67+ t.Errorf("mode = %v, want %v", mode, defaultFileMode)
68+ }
69+}
70+
71+func TestReloadReadsTheCurrentFileSystem(t *testing.T) {
72+ memory := useMemory(t)
73+ memory.AddFile("/remote/project/a.py", "1\n")
74+ b, _ := Open("/remote/project/a.py")
75+
76+ memory.AddFile("/remote/project/a.py", "2\n")
77+ changed, err := b.Reload()
78+ if err != nil || !changed || b.Text() != "2\n" {
79+ t.Errorf("Reload = %v, %v; text %q", changed, err, b.Text())
80+ }
81+}
new file mode 100644
@@ -0,0 +1,81 @@
1+package buffer
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+// useMemory makes the buffer read and write a file system that exists only in
11+// memory, so a file found there proves the buffer went through fsys.
12+func useMemory(t *testing.T) *fsystest.Memory {
13+ t.Helper()
14+ memory := fsystest.New("/remote/project")
15+ previous := fsys.Use(memory)
16+ t.Cleanup(func() { fsys.Use(previous) })
17+ return memory
18+}
19+
20+func TestOpenAndSaveGoThroughTheCurrentFileSystem(t *testing.T) {
21+ memory := useMemory(t)
22+ memory.AddFile("/remote/project/app.py", "x = 1\n")
23+
24+ b, err := Open("/remote/project/app.py")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if b.Text() != "x = 1\n" {
29+ t.Fatalf("Text() = %q", b.Text())
30+ }
31+ b.SetText("x = 2\n")
32+ if err := b.Save(); err != nil {
33+ t.Fatal(err)
34+ }
35+ if got, _ := memory.File("/remote/project/app.py"); got != "x = 2\n" {
36+ t.Errorf("saved file = %q", got)
37+ }
38+}
39+
40+func TestSavingKeepsTheModeOfTheFileItReplaces(t *testing.T) {
41+ memory := useMemory(t)
42+ if err := memory.WriteFileAtomic("/remote/project/run.sh", []byte("echo\n"), 0o755); err != nil {
43+ t.Fatal(err)
44+ }
45+
46+ b, _ := Open("/remote/project/run.sh")
47+ b.SetText("echo hi\n")
48+ if err := b.Save(); err != nil {
49+ t.Fatal(err)
50+ }
51+ if mode := memory.Mode("/remote/project/run.sh"); mode != 0o755 {
52+ t.Errorf("mode after save = %v, want 0755", mode)
53+ }
54+}
55+
56+func TestANewFileIsSavedWithTheDefaultMode(t *testing.T) {
57+ memory := useMemory(t)
58+ b, err := Open("/remote/project/new.py")
59+ if err != nil {
60+ t.Fatal(err)
61+ }
62+ b.SetText("pass\n")
63+ if err := b.Save(); err != nil {
64+ t.Fatal(err)
65+ }
66+ if mode := memory.Mode("/remote/project/new.py"); mode != defaultFileMode {
67+ t.Errorf("mode = %v, want %v", mode, defaultFileMode)
68+ }
69+}
70+
71+func TestReloadReadsTheCurrentFileSystem(t *testing.T) {
72+ memory := useMemory(t)
73+ memory.AddFile("/remote/project/a.py", "1\n")
74+ b, _ := Open("/remote/project/a.py")
75+
76+ memory.AddFile("/remote/project/a.py", "2\n")
77+ changed, err := b.Reload()
78+ if err != nil || !changed || b.Text() != "2\n" {
79+ t.Errorf("Reload = %v, %v; text %q", changed, err, b.Text())
80+ }
81+}
modified docs/diagrams/packages.drawio +87 -56
@@ -4,62 +4,93 @@
44 <root>
55 <mxCell id="0"/>
66 <mxCell id="1" parent="0"/>
7- <mxCell id="2" value="&lt;b&gt;app&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;assembly: menus, dialogs, routing&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="0" width="210" height="56" as="geometry"/></mxCell>
8- <mxCell id="3" value="&lt;b&gt;acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a coding agent in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="134" width="210" height="56" as="geometry"/></mxCell>
9- <mxCell id="4" value="&lt;b&gt;editor&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the editing widget&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="134" width="210" height="56" as="geometry"/></mxCell>
10- <mxCell id="5" value="&lt;b&gt;filetree&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project as a tree&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="134" width="210" height="56" as="geometry"/></mxCell>
11- <mxCell id="6" value="&lt;b&gt;terminal&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a shell in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="134" width="210" height="56" as="geometry"/></mxCell>
12- <mxCell id="7" value="&lt;b&gt;lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;LSP framing and the server process&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-695" y="268" width="210" height="56" as="geometry"/></mxCell>
13- <mxCell id="8" value="&lt;b&gt;settings&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s settings.toml&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="268" width="210" height="56" as="geometry"/></mxCell>
14- <mxCell id="9" value="&lt;b&gt;snippets&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s and user&amp;#x27;s snippets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="268" width="210" height="56" as="geometry"/></mxCell>
15- <mxCell id="10" value="&lt;b&gt;syntax&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;colouring, and the language registry&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="268" width="210" height="56" as="geometry"/></mxCell>
16- <mxCell id="11" value="&lt;b&gt;tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the commands a project runs on itself&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="268" width="210" height="56" as="geometry"/></mxCell>
17- <mxCell id="12" value="&lt;b&gt;ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;Turbo Vision widgets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="485" y="268" width="210" height="56" as="geometry"/></mxCell>
18- <mxCell id="13" value="&lt;b&gt;buffer&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the text of one file&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-813" y="402" width="210" height="56" as="geometry"/></mxCell>
19- <mxCell id="14" value="&lt;b&gt;jsonrpc&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;JSON-RPC 2.0 over a framed stream&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-577" y="402" width="210" height="56" as="geometry"/></mxCell>
20- <mxCell id="15" value="&lt;b&gt;main&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-341" y="402" width="210" height="56" as="geometry"/></mxCell>
21- <mxCell id="16" value="&lt;b&gt;profile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what makes one editor differ from another&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="402" width="210" height="56" as="geometry"/></mxCell>
22- <mxCell id="17" value="&lt;b&gt;projectfile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;atomic writes of a project&amp;#x27;s files&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="131" y="402" width="210" height="56" as="geometry"/></mxCell>
23- <mxCell id="18" value="&lt;b&gt;theme&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;TOML into tcell styles&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="367" y="402" width="210" height="56" as="geometry"/></mxCell>
24- <mxCell id="19" value="&lt;b&gt;version&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what build this binary is&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="603" y="402" width="210" height="56" as="geometry"/></mxCell>
25- <mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="14"><mxGeometry relative="1" as="geometry"/></mxCell>
26- <mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
27- <mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
28- <mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>
29- <mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
30- <mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
31- <mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>
32- <mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="13"><mxGeometry relative="1" as="geometry"/></mxCell>
33- <mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="4"><mxGeometry relative="1" as="geometry"/></mxCell>
34- <mxCell id="29" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="5"><mxGeometry relative="1" as="geometry"/></mxCell>
35- <mxCell id="30" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="7"><mxGeometry relative="1" as="geometry"/></mxCell>
36- <mxCell id="31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
37- <mxCell id="32" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="8"><mxGeometry relative="1" as="geometry"/></mxCell>
38- <mxCell id="33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="9"><mxGeometry relative="1" as="geometry"/></mxCell>
39- <mxCell id="34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>
40- <mxCell id="35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="6"><mxGeometry relative="1" as="geometry"/></mxCell>
41- <mxCell id="36" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
42- <mxCell id="37" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="11"><mxGeometry relative="1" as="geometry"/></mxCell>
43- <mxCell id="38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
44- <mxCell id="39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
45- <mxCell id="40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="13"><mxGeometry relative="1" as="geometry"/></mxCell>
46- <mxCell id="41" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>
47- <mxCell id="42" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
48- <mxCell id="43" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
49- <mxCell id="44" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="5" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
50- <mxCell id="45" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="5" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
51- <mxCell id="46" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="14"><mxGeometry relative="1" as="geometry"/></mxCell>
52- <mxCell id="47" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
53- <mxCell id="48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
54- <mxCell id="49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
55- <mxCell id="50" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
56- <mxCell id="51" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
57- <mxCell id="52" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="10" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
58- <mxCell id="53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="6" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
59- <mxCell id="54" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="6" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
60- <mxCell id="55" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
61- <mxCell id="56" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
62- <mxCell id="57" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
7+ <mxCell id="2" value="&lt;b&gt;app&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;assembly: menus, dialogs, routing&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-577" y="0" width="210" height="56" as="geometry"/></mxCell>
8+ <mxCell id="3" value="&lt;b&gt;configrepo&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a shared .turbo-&amp;lt;slug&amp;gt; by git clone&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-341" y="0" width="210" height="56" as="geometry"/></mxCell>
9+ <mxCell id="4" value="&lt;b&gt;fsys/fsystest&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;an in-memory fsys.FS for tests&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="0" width="210" height="56" as="geometry"/></mxCell>
10+ <mxCell id="5" value="&lt;b&gt;main&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;release scripts&amp;#x27; tests&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="131" y="0" width="210" height="56" as="geometry"/></mxCell>
11+ <mxCell id="6" value="&lt;b&gt;remote&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;another machine over the ssh command&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="367" y="0" width="210" height="56" as="geometry"/></mxCell>
12+ <mxCell id="7" value="&lt;b&gt;acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a coding agent in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-1049" y="134" width="210" height="56" as="geometry"/></mxCell>
13+ <mxCell id="8" value="&lt;b&gt;editor&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the editing widget&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-813" y="134" width="210" height="56" as="geometry"/></mxCell>
14+ <mxCell id="9" value="&lt;b&gt;filetree&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project as a tree&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-577" y="134" width="210" height="56" as="geometry"/></mxCell>
15+ <mxCell id="10" value="&lt;b&gt;lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;LSP framing and the server process&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-341" y="134" width="210" height="56" as="geometry"/></mxCell>
16+ <mxCell id="11" value="&lt;b&gt;settings&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s settings.toml&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="134" width="210" height="56" as="geometry"/></mxCell>
17+ <mxCell id="12" value="&lt;b&gt;snippets&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s and user&amp;#x27;s snippets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="131" y="134" width="210" height="56" as="geometry"/></mxCell>
18+ <mxCell id="13" value="&lt;b&gt;terminal&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a shell in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="367" y="134" width="210" height="56" as="geometry"/></mxCell>
19+ <mxCell id="14" value="&lt;b&gt;tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the commands a project runs on itself&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="603" y="134" width="210" height="56" as="geometry"/></mxCell>
20+ <mxCell id="15" value="&lt;b&gt;version&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what build this binary is&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="839" y="134" width="210" height="56" as="geometry"/></mxCell>
21+ <mxCell id="16" value="&lt;b&gt;buffer&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the text of one file&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-695" y="268" width="210" height="56" as="geometry"/></mxCell>
22+ <mxCell id="17" value="&lt;b&gt;jsonrpc&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;JSON-RPC 2.0 over a framed stream&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="268" width="210" height="56" as="geometry"/></mxCell>
23+ <mxCell id="18" value="&lt;b&gt;profile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what makes one editor differ from another&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="268" width="210" height="56" as="geometry"/></mxCell>
24+ <mxCell id="19" value="&lt;b&gt;projectfile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;atomic writes of a project&amp;#x27;s files&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="268" width="210" height="56" as="geometry"/></mxCell>
25+ <mxCell id="20" value="&lt;b&gt;syntax&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;colouring, and the language registry&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="268" width="210" height="56" as="geometry"/></mxCell>
26+ <mxCell id="21" value="&lt;b&gt;ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;Turbo Vision widgets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="485" y="268" width="210" height="56" as="geometry"/></mxCell>
27+ <mxCell id="22" value="&lt;b&gt;fsys&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the one door to the project&amp;#x27;s files&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="402" width="210" height="56" as="geometry"/></mxCell>
28+ <mxCell id="23" value="&lt;b&gt;theme&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;TOML into tcell styles&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="402" width="210" height="56" as="geometry"/></mxCell>
29+ <mxCell id="24" value="&lt;b&gt;golang.org/x/sys&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;pty, pseudo-console&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="536" width="210" height="56" as="geometry"/></mxCell>
30+ <mxCell id="25" value="&lt;b&gt;pkg/sftp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;SFTP client over ssh&amp;#x27;s channel&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="536" width="210" height="56" as="geometry"/></mxCell>
31+ <mxCell id="26" value="&lt;b&gt;tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the terminal&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="536" width="210" height="56" as="geometry"/></mxCell>
32+ <mxCell id="27" value="&lt;b&gt;toml&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;configuration files&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="536" width="210" height="56" as="geometry"/></mxCell>
33+ <mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
34+ <mxCell id="29" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
35+ <mxCell id="30" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
36+ <mxCell id="31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="20"><mxGeometry relative="1" as="geometry"/></mxCell>
37+ <mxCell id="32" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
38+ <mxCell id="33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
39+ <mxCell id="34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
40+ <mxCell id="35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
41+ <mxCell id="36" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="7"><mxGeometry relative="1" as="geometry"/></mxCell>
42+ <mxCell id="37" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
43+ <mxCell id="38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="8"><mxGeometry relative="1" as="geometry"/></mxCell>
44+ <mxCell id="39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="9"><mxGeometry relative="1" as="geometry"/></mxCell>
45+ <mxCell id="40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
46+ <mxCell id="41" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>
47+ <mxCell id="42" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
48+ <mxCell id="43" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="11"><mxGeometry relative="1" as="geometry"/></mxCell>
49+ <mxCell id="44" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
50+ <mxCell id="45" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="20"><mxGeometry relative="1" as="geometry"/></mxCell>
51+ <mxCell id="46" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
52+ <mxCell id="47" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="13"><mxGeometry relative="1" as="geometry"/></mxCell>
53+ <mxCell id="48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
54+ <mxCell id="49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="14"><mxGeometry relative="1" as="geometry"/></mxCell>
55+ <mxCell id="50" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
56+ <mxCell id="51" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="15"><mxGeometry relative="1" as="geometry"/></mxCell>
57+ <mxCell id="52" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="16" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
58+ <mxCell id="53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
59+ <mxCell id="54" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
60+ <mxCell id="55" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
61+ <mxCell id="56" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="20"><mxGeometry relative="1" as="geometry"/></mxCell>
62+ <mxCell id="57" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
63+ <mxCell id="58" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
64+ <mxCell id="59" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
65+ <mxCell id="60" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
66+ <mxCell id="61" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
67+ <mxCell id="62" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
68+ <mxCell id="63" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
69+ <mxCell id="64" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="10" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
70+ <mxCell id="65" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="10" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
71+ <mxCell id="66" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="19" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
72+ <mxCell id="67" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="6" target="25"><mxGeometry relative="1" as="geometry"/></mxCell>
73+ <mxCell id="68" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
74+ <mxCell id="69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
75+ <mxCell id="70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
76+ <mxCell id="71" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
77+ <mxCell id="72" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
78+ <mxCell id="73" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
79+ <mxCell id="74" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
80+ <mxCell id="75" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
81+ <mxCell id="76" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="20" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
82+ <mxCell id="77" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="24"><mxGeometry relative="1" as="geometry"/></mxCell>
83+ <mxCell id="78" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
84+ <mxCell id="79" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
85+ <mxCell id="80" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
86+ <mxCell id="81" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="23" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
87+ <mxCell id="82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="23" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
88+ <mxCell id="83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
89+ <mxCell id="84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
90+ <mxCell id="85" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
91+ <mxCell id="86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
92+ <mxCell id="87" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="21" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
93+ <mxCell id="88" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="21" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
6394 </root>
6495 </mxGraphModel>
6596 </diagram>
@@ -4,62 +4,93 @@
4 <root>4 <root>
5 <mxCell id="0"/>5 <mxCell id="0"/>
6 <mxCell id="1" parent="0"/>6 <mxCell id="1" parent="0"/>
7- <mxCell id="2" value="&lt;b&gt;app&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;assembly: menus, dialogs, routing&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="0" width="210" height="56" as="geometry"/></mxCell>7+ <mxCell id="2" value="&lt;b&gt;app&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;assembly: menus, dialogs, routing&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-577" y="0" width="210" height="56" as="geometry"/></mxCell>
8- <mxCell id="3" value="&lt;b&gt;acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a coding agent in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="134" width="210" height="56" as="geometry"/></mxCell>8+ <mxCell id="3" value="&lt;b&gt;configrepo&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a shared .turbo-&amp;lt;slug&amp;gt; by git clone&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-341" y="0" width="210" height="56" as="geometry"/></mxCell>
9- <mxCell id="4" value="&lt;b&gt;editor&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the editing widget&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="134" width="210" height="56" as="geometry"/></mxCell>9+ <mxCell id="4" value="&lt;b&gt;fsys/fsystest&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;an in-memory fsys.FS for tests&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="0" width="210" height="56" as="geometry"/></mxCell>
10- <mxCell id="5" value="&lt;b&gt;filetree&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project as a tree&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="134" width="210" height="56" as="geometry"/></mxCell>10+ <mxCell id="5" value="&lt;b&gt;main&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;release scripts&amp;#x27; tests&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="131" y="0" width="210" height="56" as="geometry"/></mxCell>
11- <mxCell id="6" value="&lt;b&gt;terminal&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a shell in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="134" width="210" height="56" as="geometry"/></mxCell>11+ <mxCell id="6" value="&lt;b&gt;remote&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;another machine over the ssh command&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="367" y="0" width="210" height="56" as="geometry"/></mxCell>
12- <mxCell id="7" value="&lt;b&gt;lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;LSP framing and the server process&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-695" y="268" width="210" height="56" as="geometry"/></mxCell>12+ <mxCell id="7" value="&lt;b&gt;acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a coding agent in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-1049" y="134" width="210" height="56" as="geometry"/></mxCell>
13- <mxCell id="8" value="&lt;b&gt;settings&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s settings.toml&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="268" width="210" height="56" as="geometry"/></mxCell>13+ <mxCell id="8" value="&lt;b&gt;editor&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the editing widget&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-813" y="134" width="210" height="56" as="geometry"/></mxCell>
14- <mxCell id="9" value="&lt;b&gt;snippets&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s and user&amp;#x27;s snippets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="268" width="210" height="56" as="geometry"/></mxCell>14+ <mxCell id="9" value="&lt;b&gt;filetree&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project as a tree&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-577" y="134" width="210" height="56" as="geometry"/></mxCell>
15- <mxCell id="10" value="&lt;b&gt;syntax&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;colouring, and the language registry&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="268" width="210" height="56" as="geometry"/></mxCell>15+ <mxCell id="10" value="&lt;b&gt;lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;LSP framing and the server process&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-341" y="134" width="210" height="56" as="geometry"/></mxCell>
16- <mxCell id="11" value="&lt;b&gt;tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the commands a project runs on itself&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="268" width="210" height="56" as="geometry"/></mxCell>16+ <mxCell id="11" value="&lt;b&gt;settings&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s settings.toml&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="134" width="210" height="56" as="geometry"/></mxCell>
17- <mxCell id="12" value="&lt;b&gt;ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;Turbo Vision widgets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="485" y="268" width="210" height="56" as="geometry"/></mxCell>17+ <mxCell id="12" value="&lt;b&gt;snippets&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the project&amp;#x27;s and user&amp;#x27;s snippets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="131" y="134" width="210" height="56" as="geometry"/></mxCell>
18- <mxCell id="13" value="&lt;b&gt;buffer&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the text of one file&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-813" y="402" width="210" height="56" as="geometry"/></mxCell>18+ <mxCell id="13" value="&lt;b&gt;terminal&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;a shell in a window&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="367" y="134" width="210" height="56" as="geometry"/></mxCell>
19- <mxCell id="14" value="&lt;b&gt;jsonrpc&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;JSON-RPC 2.0 over a framed stream&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-577" y="402" width="210" height="56" as="geometry"/></mxCell>19+ <mxCell id="14" value="&lt;b&gt;tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the commands a project runs on itself&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="603" y="134" width="210" height="56" as="geometry"/></mxCell>
20- <mxCell id="15" value="&lt;b&gt;main&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-341" y="402" width="210" height="56" as="geometry"/></mxCell>20+ <mxCell id="15" value="&lt;b&gt;version&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what build this binary is&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="839" y="134" width="210" height="56" as="geometry"/></mxCell>
21- <mxCell id="16" value="&lt;b&gt;profile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what makes one editor differ from another&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-105" y="402" width="210" height="56" as="geometry"/></mxCell>21+ <mxCell id="16" value="&lt;b&gt;buffer&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the text of one file&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-695" y="268" width="210" height="56" as="geometry"/></mxCell>
22- <mxCell id="17" value="&lt;b&gt;projectfile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;atomic writes of a project&amp;#x27;s files&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="131" y="402" width="210" height="56" as="geometry"/></mxCell>22+ <mxCell id="17" value="&lt;b&gt;jsonrpc&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;JSON-RPC 2.0 over a framed stream&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="268" width="210" height="56" as="geometry"/></mxCell>
23- <mxCell id="18" value="&lt;b&gt;theme&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;TOML into tcell styles&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="367" y="402" width="210" height="56" as="geometry"/></mxCell>23+ <mxCell id="18" value="&lt;b&gt;profile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what makes one editor differ from another&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="268" width="210" height="56" as="geometry"/></mxCell>
24- <mxCell id="19" value="&lt;b&gt;version&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;what build this binary is&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="603" y="402" width="210" height="56" as="geometry"/></mxCell>24+ <mxCell id="19" value="&lt;b&gt;projectfile&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;atomic writes of a project&amp;#x27;s files&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="268" width="210" height="56" as="geometry"/></mxCell>
25- <mxCell id="20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="14"><mxGeometry relative="1" as="geometry"/></mxCell>25+ <mxCell id="20" value="&lt;b&gt;syntax&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;colouring, and the language registry&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="268" width="210" height="56" as="geometry"/></mxCell>
26- <mxCell id="21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>26+ <mxCell id="21" value="&lt;b&gt;ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;Turbo Vision widgets&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="485" y="268" width="210" height="56" as="geometry"/></mxCell>
27- <mxCell id="22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>27+ <mxCell id="22" value="&lt;b&gt;fsys&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the one door to the project&amp;#x27;s files&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="402" width="210" height="56" as="geometry"/></mxCell>
28- <mxCell id="23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>28+ <mxCell id="23" value="&lt;b&gt;theme&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;TOML into tcell styles&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="402" width="210" height="56" as="geometry"/></mxCell>
29- <mxCell id="24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>29+ <mxCell id="24" value="&lt;b&gt;golang.org/x/sys&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;pty, pseudo-console&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-459" y="536" width="210" height="56" as="geometry"/></mxCell>
30- <mxCell id="25" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>30+ <mxCell id="25" value="&lt;b&gt;pkg/sftp&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;SFTP client over ssh&amp;#x27;s channel&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="-223" y="536" width="210" height="56" as="geometry"/></mxCell>
31- <mxCell id="26" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>31+ <mxCell id="26" value="&lt;b&gt;tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;the terminal&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="13" y="536" width="210" height="56" as="geometry"/></mxCell>
32- <mxCell id="27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="13"><mxGeometry relative="1" as="geometry"/></mxCell>32+ <mxCell id="27" value="&lt;b&gt;toml&lt;/b&gt;&lt;br/&gt;&lt;font style=&#x27;font-size:10px&#x27;&gt;configuration files&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;fontSize=12;verticalAlign=middle;" vertex="1" parent="1"><mxGeometry x="249" y="536" width="210" height="56" as="geometry"/></mxCell>
33- <mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="4"><mxGeometry relative="1" as="geometry"/></mxCell>33+ <mxCell id="28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
34- <mxCell id="29" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="5"><mxGeometry relative="1" as="geometry"/></mxCell>34+ <mxCell id="29" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
35- <mxCell id="30" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="7"><mxGeometry relative="1" as="geometry"/></mxCell>35+ <mxCell id="30" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
36- <mxCell id="31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>36+ <mxCell id="31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="20"><mxGeometry relative="1" as="geometry"/></mxCell>
37- <mxCell id="32" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="8"><mxGeometry relative="1" as="geometry"/></mxCell>37+ <mxCell id="32" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
38- <mxCell id="33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="9"><mxGeometry relative="1" as="geometry"/></mxCell>38+ <mxCell id="33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
39- <mxCell id="34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>39+ <mxCell id="34" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
40- <mxCell id="35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="6"><mxGeometry relative="1" as="geometry"/></mxCell>40+ <mxCell id="35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
41- <mxCell id="36" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>41+ <mxCell id="36" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="7"><mxGeometry relative="1" as="geometry"/></mxCell>
42- <mxCell id="37" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="11"><mxGeometry relative="1" as="geometry"/></mxCell>42+ <mxCell id="37" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
43- <mxCell id="38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>43+ <mxCell id="38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="8"><mxGeometry relative="1" as="geometry"/></mxCell>
44- <mxCell id="39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>44+ <mxCell id="39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="9"><mxGeometry relative="1" as="geometry"/></mxCell>
45- <mxCell id="40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="13"><mxGeometry relative="1" as="geometry"/></mxCell>45+ <mxCell id="40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
46- <mxCell id="41" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>46+ <mxCell id="41" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="10"><mxGeometry relative="1" as="geometry"/></mxCell>
47- <mxCell id="42" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>47+ <mxCell id="42" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
48- <mxCell id="43" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="4" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>48+ <mxCell id="43" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="11"><mxGeometry relative="1" as="geometry"/></mxCell>
49- <mxCell id="44" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="5" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>49+ <mxCell id="44" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>
50- <mxCell id="45" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="5" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>50+ <mxCell id="45" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="20"><mxGeometry relative="1" as="geometry"/></mxCell>
51- <mxCell id="46" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="14"><mxGeometry relative="1" as="geometry"/></mxCell>51+ <mxCell id="46" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
52- <mxCell id="47" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="7" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>52+ <mxCell id="47" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="13"><mxGeometry relative="1" as="geometry"/></mxCell>
53- <mxCell id="48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>53+ <mxCell id="48" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
54- <mxCell id="49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>54+ <mxCell id="49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="14"><mxGeometry relative="1" as="geometry"/></mxCell>
55- <mxCell id="50" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>55+ <mxCell id="50" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
56- <mxCell id="51" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>56+ <mxCell id="51" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="2" target="15"><mxGeometry relative="1" as="geometry"/></mxCell>
57- <mxCell id="52" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="10" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>57+ <mxCell id="52" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="16" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
58- <mxCell id="53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="6" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>58+ <mxCell id="53" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
59- <mxCell id="54" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="6" target="12"><mxGeometry relative="1" as="geometry"/></mxCell>59+ <mxCell id="54" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="3" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
60- <mxCell id="55" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>60+ <mxCell id="55" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="16"><mxGeometry relative="1" as="geometry"/></mxCell>
61- <mxCell id="56" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>61+ <mxCell id="56" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="20"><mxGeometry relative="1" as="geometry"/></mxCell>
62- <mxCell id="57" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>62+ <mxCell id="57" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
63+ <mxCell id="58" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
64+ <mxCell id="59" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="8" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
65+ <mxCell id="60" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
66+ <mxCell id="61" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
67+ <mxCell id="62" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
68+ <mxCell id="63" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="9" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
69+ <mxCell id="64" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="10" target="17"><mxGeometry relative="1" as="geometry"/></mxCell>
70+ <mxCell id="65" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="10" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
71+ <mxCell id="66" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="19" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
72+ <mxCell id="67" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="6" target="25"><mxGeometry relative="1" as="geometry"/></mxCell>
73+ <mxCell id="68" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
74+ <mxCell id="69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
75+ <mxCell id="70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
76+ <mxCell id="71" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="11" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
77+ <mxCell id="72" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
78+ <mxCell id="73" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
79+ <mxCell id="74" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
80+ <mxCell id="75" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="12" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
81+ <mxCell id="76" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="20" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
82+ <mxCell id="77" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="24"><mxGeometry relative="1" as="geometry"/></mxCell>
83+ <mxCell id="78" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
84+ <mxCell id="79" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
85+ <mxCell id="80" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="13" target="21"><mxGeometry relative="1" as="geometry"/></mxCell>
86+ <mxCell id="81" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="23" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
87+ <mxCell id="82" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="23" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
88+ <mxCell id="83" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="22"><mxGeometry relative="1" as="geometry"/></mxCell>
89+ <mxCell id="84" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="18"><mxGeometry relative="1" as="geometry"/></mxCell>
90+ <mxCell id="85" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="19"><mxGeometry relative="1" as="geometry"/></mxCell>
91+ <mxCell id="86" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="14" target="27"><mxGeometry relative="1" as="geometry"/></mxCell>
92+ <mxCell id="87" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="21" target="26"><mxGeometry relative="1" as="geometry"/></mxCell>
93+ <mxCell id="88" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;endFill=1;strokeColor=#6c8ebf;" edge="1" parent="1" source="21" target="23"><mxGeometry relative="1" as="geometry"/></mxCell>
63 </root>94 </root>
64 </mxGraphModel>95 </mxGraphModel>
65 </diagram>96 </diagram>
modified docs/en/README.md +4 -0
@@ -13,6 +13,7 @@ This documentation follows [Diátaxis](https://diataxis.fr): four kinds of page,
1313 - [Add a language](how-to/add-a-language.md) — write a scanner and register it.
1414 - [Talk to a language server](how-to/talk-to-a-language-server.md) — point the editor at gopls, rust-analyzer, or anything else that speaks LSP.
1515 - [Write the starter files](how-to/write-the-starter-files.md) — the settings, snippets and tools templates your editor offers to create.
16+- [Add remote editing](how-to/add-remote-editing.md) — give an editor `-ssh user@machine[:dir]`, to edit another machine's files.
1617 - [Run the tests](how-to/run-the-tests.md) — the one command, and what it covers.
1718 - [Test without publishing](how-to/test-without-publishing.md) — run the editors against a turbo-core you have only changed locally.
1819 - [Release the library](how-to/release-the-library.md) — tagging a version the editors can depend on.
@@ -23,11 +24,14 @@ This documentation follows [Diátaxis](https://diataxis.fr): four kinds of page,
2324 - [Packages](reference/packages.md) — what each package holds, and what it may depend on.
2425 - [The syntax extension point](reference/syntax.md) — `Definition`, `Register`, `LineScanner`, `LineIndex`.
2526 - [The app API](reference/app.md) — what a command may call on a running editor.
27+- [fsys](reference/fsys.md) — the one door to the project's files.
28+- [remote](reference/remote.md) — another machine over the `ssh` command.
2629
2730 ## [Explanation](explanation/) — why it is like this
2831
2932 - [Architecture](explanation/architecture.md) — the shape of the library and the rule that keeps it that shape.
3033 - [What belongs here](explanation/what-belongs-here.md) — the line between the library and an editor built on it, and how it was drawn.
34+- [Remote editing](explanation/remote-editing.md) — why `-ssh` drives the ssh command, shares one connection, and moves files over SFTP.
3135
3236 ## Where the editors are
3337
@@ -13,6 +13,7 @@ This documentation follows [Diátaxis](https://diataxis.fr): four kinds of page,
13 - [Add a language](how-to/add-a-language.md) — write a scanner and register it.13 - [Add a language](how-to/add-a-language.md) — write a scanner and register it.
14 - [Talk to a language server](how-to/talk-to-a-language-server.md) — point the editor at gopls, rust-analyzer, or anything else that speaks LSP.14 - [Talk to a language server](how-to/talk-to-a-language-server.md) — point the editor at gopls, rust-analyzer, or anything else that speaks LSP.
15 - [Write the starter files](how-to/write-the-starter-files.md) — the settings, snippets and tools templates your editor offers to create.15 - [Write the starter files](how-to/write-the-starter-files.md) — the settings, snippets and tools templates your editor offers to create.
16+- [Add remote editing](how-to/add-remote-editing.md) — give an editor `-ssh user@machine[:dir]`, to edit another machine's files.
16 - [Run the tests](how-to/run-the-tests.md) — the one command, and what it covers.17 - [Run the tests](how-to/run-the-tests.md) — the one command, and what it covers.
17 - [Test without publishing](how-to/test-without-publishing.md) — run the editors against a turbo-core you have only changed locally.18 - [Test without publishing](how-to/test-without-publishing.md) — run the editors against a turbo-core you have only changed locally.
18 - [Release the library](how-to/release-the-library.md) — tagging a version the editors can depend on.19 - [Release the library](how-to/release-the-library.md) — tagging a version the editors can depend on.
@@ -23,11 +24,14 @@ This documentation follows [Diátaxis](https://diataxis.fr): four kinds of page,
23 - [Packages](reference/packages.md) — what each package holds, and what it may depend on.24 - [Packages](reference/packages.md) — what each package holds, and what it may depend on.
24 - [The syntax extension point](reference/syntax.md) — `Definition`, `Register`, `LineScanner`, `LineIndex`.25 - [The syntax extension point](reference/syntax.md) — `Definition`, `Register`, `LineScanner`, `LineIndex`.
25 - [The app API](reference/app.md) — what a command may call on a running editor.26 - [The app API](reference/app.md) — what a command may call on a running editor.
27+- [fsys](reference/fsys.md) — the one door to the project's files.
28+- [remote](reference/remote.md) — another machine over the `ssh` command.
26 29
27 ## [Explanation](explanation/) — why it is like this30 ## [Explanation](explanation/) — why it is like this
28 31
29 - [Architecture](explanation/architecture.md) — the shape of the library and the rule that keeps it that shape.32 - [Architecture](explanation/architecture.md) — the shape of the library and the rule that keeps it that shape.
30 - [What belongs here](explanation/what-belongs-here.md) — the line between the library and an editor built on it, and how it was drawn.33 - [What belongs here](explanation/what-belongs-here.md) — the line between the library and an editor built on it, and how it was drawn.
34+- [Remote editing](explanation/remote-editing.md) — why `-ssh` drives the ssh command, shares one connection, and moves files over SFTP.
31 35
32 ## Where the editors are36 ## Where the editors are
33 37
modified docs/en/explanation/architecture.md +6 -1
@@ -2,7 +2,7 @@
22
33 ## What is this about?
44
5-turbo-core is a terminal IDE with a hole where the language goes. Eighteen packages implement everything an editor does — text, undo, windows, menus, dialogs, themes, a VT emulator, a file tree, an LSP client, a coding agent — and none of them knows what language is being edited. An editor built on it is a command, a `profile.Profile`, and a scanner.
5+turbo-core is a terminal IDE with a hole where the language goes. Twenty-one packages implement everything an editor does — text, undo, windows, menus, dialogs, themes, a VT emulator, a file tree, an LSP client, a coding agent, editing over ssh — and none of them knows what language is being edited. An editor built on it is a command, a `profile.Profile`, and a scanner.
66
77 The library was not designed that way. It was extracted from Turbo Go, which had been a single 35,000-line program for a fortnight, at the moment a second editor was wanted. Nearly all of it moved unchanged; the seam is where it turned out to have to be, not where anybody predicted.
88
@@ -44,6 +44,10 @@ It is not safe from two goroutines at once, and there is no reason for it to be.
4444
4545 This looks inconsistent and is deliberate. A package that takes a profile it uses one field of would be a package that depends on the editor's whole identity to answer a question about a directory.
4646
47+## Why the project's files have one door
48+
49+Every read and write of a *project* file — the source, the tree, the Open dialog, the project's own TOML files — goes through `fsys`, and nothing else in the library touches those files with package `os`. That is what lets an editor started with `-ssh` swap the local disk for another machine with one call, `fsys.Use`, before anything reads. It is package-level state for the reasons the syntax registry is; the whole argument, and what was deliberately left on the local disk, is in [remote editing](remote-editing.md).
50+
4751 ## How it relates to the rest
4852
4953 The editors built on it are the proof the seam is real. Turbo Go's own packages went from fourteen to one; Turbo Rust was built on the library without changing it, and the four defects that surfaced while building it were all in Turbo Rust's own scanner rather than in the library — which is the outcome the extraction was for. Turbo Python, the third, was built against a published tag with no change to the library at all.
@@ -52,6 +56,7 @@ The editors built on it are the proof the seam is real. Turbo Go's own packages
5256
5357 - What is in each package: [packages reference](../reference/packages.md)
5458 - Where the line falls: [what belongs here](what-belongs-here.md)
59+- Editing another machine's files: [remote editing](remote-editing.md)
5560
5661 ## Why jsonrpc was pulled out of lsp
5762
@@ -2,7 +2,7 @@
2 2
3 ## What is this about?3 ## What is this about?
4 4
5-turbo-core is a terminal IDE with a hole where the language goes. Eighteen packages implement everything an editor does — text, undo, windows, menus, dialogs, themes, a VT emulator, a file tree, an LSP client, a coding agent — and none of them knows what language is being edited. An editor built on it is a command, a `profile.Profile`, and a scanner.5+turbo-core is a terminal IDE with a hole where the language goes. Twenty-one packages implement everything an editor does — text, undo, windows, menus, dialogs, themes, a VT emulator, a file tree, an LSP client, a coding agent, editing over ssh — and none of them knows what language is being edited. An editor built on it is a command, a `profile.Profile`, and a scanner.
6 6
7 The library was not designed that way. It was extracted from Turbo Go, which had been a single 35,000-line program for a fortnight, at the moment a second editor was wanted. Nearly all of it moved unchanged; the seam is where it turned out to have to be, not where anybody predicted.7 The library was not designed that way. It was extracted from Turbo Go, which had been a single 35,000-line program for a fortnight, at the moment a second editor was wanted. Nearly all of it moved unchanged; the seam is where it turned out to have to be, not where anybody predicted.
8 8
@@ -44,6 +44,10 @@ It is not safe from two goroutines at once, and there is no reason for it to be.
44 44
45 This looks inconsistent and is deliberate. A package that takes a profile it uses one field of would be a package that depends on the editor's whole identity to answer a question about a directory.45 This looks inconsistent and is deliberate. A package that takes a profile it uses one field of would be a package that depends on the editor's whole identity to answer a question about a directory.
46 46
47+## Why the project's files have one door
48+
49+Every read and write of a *project* file — the source, the tree, the Open dialog, the project's own TOML files — goes through `fsys`, and nothing else in the library touches those files with package `os`. That is what lets an editor started with `-ssh` swap the local disk for another machine with one call, `fsys.Use`, before anything reads. It is package-level state for the reasons the syntax registry is; the whole argument, and what was deliberately left on the local disk, is in [remote editing](remote-editing.md).
50+
47 ## How it relates to the rest51 ## How it relates to the rest
48 52
49 The editors built on it are the proof the seam is real. Turbo Go's own packages went from fourteen to one; Turbo Rust was built on the library without changing it, and the four defects that surfaced while building it were all in Turbo Rust's own scanner rather than in the library — which is the outcome the extraction was for. Turbo Python, the third, was built against a published tag with no change to the library at all.53 The editors built on it are the proof the seam is real. Turbo Go's own packages went from fourteen to one; Turbo Rust was built on the library without changing it, and the four defects that surfaced while building it were all in Turbo Rust's own scanner rather than in the library — which is the outcome the extraction was for. Turbo Python, the third, was built against a published tag with no change to the library at all.
@@ -52,6 +56,7 @@ The editors built on it are the proof the seam is real. Turbo Go's own packages
52 56
53 - What is in each package: [packages reference](../reference/packages.md)57 - What is in each package: [packages reference](../reference/packages.md)
54 - Where the line falls: [what belongs here](what-belongs-here.md)58 - Where the line falls: [what belongs here](what-belongs-here.md)
59+- Editing another machine's files: [remote editing](remote-editing.md)
55 60
56 ## Why jsonrpc was pulled out of lsp61 ## Why jsonrpc was pulled out of lsp
57 62
added docs/en/explanation/remote-editing.md +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+# Remote editing — explanation
2+
3+## What is this about?
4+
5+`turbo-python -ssh bob@machine:/srv/app` edits the files of another machine as if they were local: the windows, the project tree, the Open dialog and the project's settings, snippets and tools files are the remote ones; a terminal window is a shell over there; a tool runs over there. Every editor gets it from turbo-core the same way — two packages, `fsys` and `remote`, and one call, `app.UseRemote`.
6+
7+This page is about why it is built the way it is: why the editor drives the `ssh` command rather than speaking SSH itself, why files travel over SFTP, why the file system is package-level state, and what was deliberately left on the local machine.
8+
9+## One door for the files
10+
11+Before this feature, about fifty places in turbo-core called `os.ReadFile`, `os.Stat`, `os.Getwd` and their relatives directly. Remote editing needed all of those that concern *the project* to go somewhere else, and none of those that concern *the person* to move.
12+
13+So the project's files now have one door, `fsys`, and it is package-level state set once at start-up. That is the same shape — and the same argument — as `syntax.Register` (see [architecture](architecture.md)): the choice is made once per process, before anything reads; nothing changes it afterwards; and the alternative, threading a file-system value through `buffer`, `filetree`, `settings`, `snippets`, `tools`, `projectfile` and every call site in `app`, would have changed the signature of half the library for a decision nobody takes twice. The cost is the usual one for global state: a test that swaps it must put it back, and none of the packages that use it can run tests that swap it in parallel. None did.
14+
15+The line between "the project" and "the person" is drawn where it already was. The user's themes and own snippets are habits, and follow the person to whatever machine they edit. A language server's executable and a coding agent are programs on this machine. `-load-config` copies into the local working directory, which is where it is typed.
16+
17+## Why the ssh command, and not an SSH library
18+
19+Go has a good SSH client, `golang.org/x/crypto/ssh`. It was rejected, for the reason `configrepo` runs `git` instead of speaking smart HTTP: the installed command already knows everything the person has set up, and a library knows none of it.
20+
21+The ssh command reads `~/.ssh/config` — `Host` aliases, ports, `User`, `IdentityFile`, `ProxyJump` through a bastion — and uses the agent, `known_hosts`, a key's passphrase, a password, a hardware key. A library would have had to reimplement each of those, one at a time, and the dangerous one first: accepting a host key the person has never seen is a question with a security answer, and OpenSSH already asks it correctly.
22+
23+The cost is that the editor starts processes rather than calling functions, which is why `ControlMaster` matters.
24+
25+## Why ControlMaster
26+
27+Without it, every ssh command is a new connection: a TCP handshake, a key exchange, an authentication — and, with a password, a prompt, every time. The editor opens a shell here, runs a tool there, and stats every open file once a second.
28+
29+`ControlMaster` makes the first connection the master and leaves a Unix socket behind it; every later `ssh -S <socket>` travels through that one authenticated connection in milliseconds and asks nothing. So the editor logs in once, in the foreground, **before it takes the screen** — which is the only moment ssh can ask a question on the terminal — and passes `BatchMode=yes` to everything afterwards, so that a lost master fails loudly instead of drawing a password prompt over the editor.
30+
31+`ControlPersist=60` keeps the master alive for a minute after its last user. The editor holds the SFTP channel open until it closes, so the minute only matters after a crash: the connection then goes away on its own.
32+
33+The cost is Windows: its OpenSSH port has no ControlMaster, and `Connect` returns `ErrUnsupported` there.
34+
35+## Why SFTP for the files, and not shell commands
36+
37+Once a shared connection exists, the cheapest-looking way to read a file is `ssh … cat file`, and to list a directory `ssh … ls`. It was rejected for two reasons.
38+
39+The output of `ls` and `stat` is not portable — GNU and BSD disagree on flags and on formats — and a file watcher built on parsing it is a watcher that breaks on the first macOS server. And a process per operation means a process per open file per second for the watcher alone.
40+
41+SFTP is the protocol OpenSSH already ships for exactly this, over one channel of the same connection (`ssh -s … sftp`): binary, portable, and one request per operation. The client is `github.com/pkg/sftp`, the one real dependency the feature added. Writing the dozen SFTP messages the editor needs by hand was considered and rejected as protocol code to maintain for no gain.
42+
43+An atomic save survives the move: the new content is written to a temporary file beside the target and renamed over it with `posix-rename@openssh.com`, which replaces the target in one step, as `rename(2)` does locally.
44+
45+## Why every remote command is `/bin/sh -c '…'`
46+
47+ssh hands the command it is given to the user's *login* shell, which may be fish, or anything else that does not read `cd dir && exec …` the way sh does. So every command sent is `exec /bin/sh -c '<script>'`, quoted once for the login shell: a tool's command line means over there what it means locally — `/bin/sh -c`, pipes and `&&` included — and a terminal starts the user's own shell with `exec "${SHELL:-/bin/sh}" -l` from inside sh.
48+
49+## What is switched off, and why
50+
51+- **The language server.** The servers an editor knows how to find are on this machine; the files they would be asked about are not. Starting pylsp remotely over the same connection is possible, but the paths in its answers would then have to be translated, and the server would have to be installed there. It is left for later rather than half done.
52+- **Agent windows.** An agent is a program on this machine that reads and writes this machine's files. One running while the editor shows the remote project would be editing a different project from the one on screen. The Agent menu says so rather than offering it.
53+
54+## Rejected alternatives
55+
56+- **Mounting with sshfs.** Needs macFUSE on macOS, a kernel extension the person has to install and approve, and leaves the terminals and tools local.
57+- **Copying the editor to the machine and running it there.** The binary is built for the person's machine — `darwin/arm64` — and the server is usually `linux/amd64`; and the editor would lose the local clipboard and the person's themes.
58+- **An interface threaded through every package** instead of `fsys.Use`. See "One door for the files" above.
59+
60+## How it relates to the rest
61+
62+`fsys` sits below everything that touches a project file, with no dependencies of its own. `remote` does not even import it — its `FS` has the right methods, which is all a Go interface asks. It sits beside `configrepo`, since both drive an installed command, and `app` knows it only through the three-method `app.Remote` interface, so its tests use a fake that runs `/bin/sh` locally. Each editor adds the flag in its `main.go`; the reference is [remote](../reference/remote.md), and the recipe [add remote editing](../how-to/add-remote-editing.md).
new file mode 100644
@@ -0,0 +1,62 @@
1+# Remote editing — explanation
2+
3+## What is this about?
4+
5+`turbo-python -ssh bob@machine:/srv/app` edits the files of another machine as if they were local: the windows, the project tree, the Open dialog and the project's settings, snippets and tools files are the remote ones; a terminal window is a shell over there; a tool runs over there. Every editor gets it from turbo-core the same way — two packages, `fsys` and `remote`, and one call, `app.UseRemote`.
6+
7+This page is about why it is built the way it is: why the editor drives the `ssh` command rather than speaking SSH itself, why files travel over SFTP, why the file system is package-level state, and what was deliberately left on the local machine.
8+
9+## One door for the files
10+
11+Before this feature, about fifty places in turbo-core called `os.ReadFile`, `os.Stat`, `os.Getwd` and their relatives directly. Remote editing needed all of those that concern *the project* to go somewhere else, and none of those that concern *the person* to move.
12+
13+So the project's files now have one door, `fsys`, and it is package-level state set once at start-up. That is the same shape — and the same argument — as `syntax.Register` (see [architecture](architecture.md)): the choice is made once per process, before anything reads; nothing changes it afterwards; and the alternative, threading a file-system value through `buffer`, `filetree`, `settings`, `snippets`, `tools`, `projectfile` and every call site in `app`, would have changed the signature of half the library for a decision nobody takes twice. The cost is the usual one for global state: a test that swaps it must put it back, and none of the packages that use it can run tests that swap it in parallel. None did.
14+
15+The line between "the project" and "the person" is drawn where it already was. The user's themes and own snippets are habits, and follow the person to whatever machine they edit. A language server's executable and a coding agent are programs on this machine. `-load-config` copies into the local working directory, which is where it is typed.
16+
17+## Why the ssh command, and not an SSH library
18+
19+Go has a good SSH client, `golang.org/x/crypto/ssh`. It was rejected, for the reason `configrepo` runs `git` instead of speaking smart HTTP: the installed command already knows everything the person has set up, and a library knows none of it.
20+
21+The ssh command reads `~/.ssh/config` — `Host` aliases, ports, `User`, `IdentityFile`, `ProxyJump` through a bastion — and uses the agent, `known_hosts`, a key's passphrase, a password, a hardware key. A library would have had to reimplement each of those, one at a time, and the dangerous one first: accepting a host key the person has never seen is a question with a security answer, and OpenSSH already asks it correctly.
22+
23+The cost is that the editor starts processes rather than calling functions, which is why `ControlMaster` matters.
24+
25+## Why ControlMaster
26+
27+Without it, every ssh command is a new connection: a TCP handshake, a key exchange, an authentication — and, with a password, a prompt, every time. The editor opens a shell here, runs a tool there, and stats every open file once a second.
28+
29+`ControlMaster` makes the first connection the master and leaves a Unix socket behind it; every later `ssh -S <socket>` travels through that one authenticated connection in milliseconds and asks nothing. So the editor logs in once, in the foreground, **before it takes the screen** — which is the only moment ssh can ask a question on the terminal — and passes `BatchMode=yes` to everything afterwards, so that a lost master fails loudly instead of drawing a password prompt over the editor.
30+
31+`ControlPersist=60` keeps the master alive for a minute after its last user. The editor holds the SFTP channel open until it closes, so the minute only matters after a crash: the connection then goes away on its own.
32+
33+The cost is Windows: its OpenSSH port has no ControlMaster, and `Connect` returns `ErrUnsupported` there.
34+
35+## Why SFTP for the files, and not shell commands
36+
37+Once a shared connection exists, the cheapest-looking way to read a file is `ssh … cat file`, and to list a directory `ssh … ls`. It was rejected for two reasons.
38+
39+The output of `ls` and `stat` is not portable — GNU and BSD disagree on flags and on formats — and a file watcher built on parsing it is a watcher that breaks on the first macOS server. And a process per operation means a process per open file per second for the watcher alone.
40+
41+SFTP is the protocol OpenSSH already ships for exactly this, over one channel of the same connection (`ssh -s … sftp`): binary, portable, and one request per operation. The client is `github.com/pkg/sftp`, the one real dependency the feature added. Writing the dozen SFTP messages the editor needs by hand was considered and rejected as protocol code to maintain for no gain.
42+
43+An atomic save survives the move: the new content is written to a temporary file beside the target and renamed over it with `posix-rename@openssh.com`, which replaces the target in one step, as `rename(2)` does locally.
44+
45+## Why every remote command is `/bin/sh -c '…'`
46+
47+ssh hands the command it is given to the user's *login* shell, which may be fish, or anything else that does not read `cd dir && exec …` the way sh does. So every command sent is `exec /bin/sh -c '<script>'`, quoted once for the login shell: a tool's command line means over there what it means locally — `/bin/sh -c`, pipes and `&&` included — and a terminal starts the user's own shell with `exec "${SHELL:-/bin/sh}" -l` from inside sh.
48+
49+## What is switched off, and why
50+
51+- **The language server.** The servers an editor knows how to find are on this machine; the files they would be asked about are not. Starting pylsp remotely over the same connection is possible, but the paths in its answers would then have to be translated, and the server would have to be installed there. It is left for later rather than half done.
52+- **Agent windows.** An agent is a program on this machine that reads and writes this machine's files. One running while the editor shows the remote project would be editing a different project from the one on screen. The Agent menu says so rather than offering it.
53+
54+## Rejected alternatives
55+
56+- **Mounting with sshfs.** Needs macFUSE on macOS, a kernel extension the person has to install and approve, and leaves the terminals and tools local.
57+- **Copying the editor to the machine and running it there.** The binary is built for the person's machine — `darwin/arm64` — and the server is usually `linux/amd64`; and the editor would lose the local clipboard and the person's themes.
58+- **An interface threaded through every package** instead of `fsys.Use`. See "One door for the files" above.
59+
60+## How it relates to the rest
61+
62+`fsys` sits below everything that touches a project file, with no dependencies of its own. `remote` does not even import it — its `FS` has the right methods, which is all a Go interface asks. It sits beside `configrepo`, since both drive an installed command, and `app` knows it only through the three-method `app.Remote` interface, so its tests use a fake that runs `/bin/sh` locally. Each editor adds the flag in its `main.go`; the reference is [remote](../reference/remote.md), and the recipe [add remote editing](../how-to/add-remote-editing.md).
added docs/en/how-to/add-remote-editing.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+# How to add remote editing to an editor
2+
3+This guide gives an editor built on turbo-core a `-ssh user@machine[:directory]` flag, so it edits another machine's files, and runs its terminals and tools there. It assumes your `main.go` has the shape of the one in [build an editor](../tutorials/build-an-editor.md): flags, `loadProjectSettings`, `newScreen`, `app.New`, `StartLanguageServer`, `Run`.
4+
5+## Steps
6+
7+1. Import the two packages:
8+
9+ ```go
10+ "rickub.com/turbo-editors/turbo-core/fsys"
11+ "rickub.com/turbo-editors/turbo-core/remote"
12+ ```
13+
14+2. Add the flag. Go's `flag` package accepts `-ssh` and `--ssh` alike:
15+
16+ ```go
17+ flag.StringVar(&opts.ssh, "ssh", "", "edit the files of another machine over ssh: user@machine[:directory]")
18+ ```
19+
20+3. Add the function that connects and switches every file read to the machine:
21+
22+ ```go
23+ func connectRemote(target string) (*remote.Session, error) {
24+ if target == "" {
25+ return nil, nil
26+ }
27+ session, err := remote.Open(context.Background(), target)
28+ if err != nil {
29+ return nil, err
30+ }
31+ fsys.Use(session.FS())
32+ return session, nil
33+ }
34+ ```
35+
36+4. Call it first in `edit`, **before** `newScreen` — ssh may need the terminal to ask for a password — and close it on the way out:
37+
38+ ```go
39+ session, err := connectRemote(opts.ssh)
40+ if err != nil {
41+ return err
42+ }
43+ if session != nil {
44+ defer session.Close()
45+ }
46+ ```
47+
48+5. Read the project directory through `fsys`, so that it is the remote one:
49+
50+ ```go
51+ project, err := fsys.Getwd() // was os.Getwd()
52+ ```
53+
54+6. Tell the editor, and do not start a local language server for remote files:
55+
56+ ```go
57+ editor := app.New(screen, themeName, p)
58+ if session != nil {
59+ editor.UseRemote(session)
60+ }
61+ // …
62+ if !opts.noLSP && session == nil {
63+ editor.StartLanguageServer(ctx, app.ProjectRoot(p, opts.files))
64+ }
65+ ```
66+
67+7. Build, then try it against the demo machine in `turbo-editors/demo-remote-machine` (its README says how to start it):
68+
69+ ```bash
70+ go build -o bin/turbo-x . && ./bin/turbo-x -ssh turbo-demo:workspace/hello-python
71+ ```
72+
73+## Variants
74+
75+- **Testing the flag without a machine.** Split `parseFlags` into a function taking a `*flag.FlagSet` and the arguments, and give it `flag.NewFlagSet(name, flag.ContinueOnError)` in a test. A bad target such as `-oProxyCommand=x` is refused by `connectRemote` before anything runs, so it can be tested with no ssh at all.
76+- **Testing file access without a machine.** `fsys.Use(fsystest.New("/remote/project"))` in a test makes every read and write go to memory; see [fsys](../reference/fsys.md).
77+- **Your editor is built against the published library.** `fsys` and `remote` arrived after v1.0.4; require the version that has them, or use a workspace — [test without publishing](test-without-publishing.md).
78+
79+## See also
80+
81+- Every function used here: [remote](../reference/remote.md), [fsys](../reference/fsys.md), [the app API](../reference/app.md)
82+- What changes in the editor, and why: [remote editing](../explanation/remote-editing.md)
new file mode 100644
@@ -0,0 +1,82 @@
1+# How to add remote editing to an editor
2+
3+This guide gives an editor built on turbo-core a `-ssh user@machine[:directory]` flag, so it edits another machine's files, and runs its terminals and tools there. It assumes your `main.go` has the shape of the one in [build an editor](../tutorials/build-an-editor.md): flags, `loadProjectSettings`, `newScreen`, `app.New`, `StartLanguageServer`, `Run`.
4+
5+## Steps
6+
7+1. Import the two packages:
8+
9+ ```go
10+ "rickub.com/turbo-editors/turbo-core/fsys"
11+ "rickub.com/turbo-editors/turbo-core/remote"
12+ ```
13+
14+2. Add the flag. Go's `flag` package accepts `-ssh` and `--ssh` alike:
15+
16+ ```go
17+ flag.StringVar(&opts.ssh, "ssh", "", "edit the files of another machine over ssh: user@machine[:directory]")
18+ ```
19+
20+3. Add the function that connects and switches every file read to the machine:
21+
22+ ```go
23+ func connectRemote(target string) (*remote.Session, error) {
24+ if target == "" {
25+ return nil, nil
26+ }
27+ session, err := remote.Open(context.Background(), target)
28+ if err != nil {
29+ return nil, err
30+ }
31+ fsys.Use(session.FS())
32+ return session, nil
33+ }
34+ ```
35+
36+4. Call it first in `edit`, **before** `newScreen` — ssh may need the terminal to ask for a password — and close it on the way out:
37+
38+ ```go
39+ session, err := connectRemote(opts.ssh)
40+ if err != nil {
41+ return err
42+ }
43+ if session != nil {
44+ defer session.Close()
45+ }
46+ ```
47+
48+5. Read the project directory through `fsys`, so that it is the remote one:
49+
50+ ```go
51+ project, err := fsys.Getwd() // was os.Getwd()
52+ ```
53+
54+6. Tell the editor, and do not start a local language server for remote files:
55+
56+ ```go
57+ editor := app.New(screen, themeName, p)
58+ if session != nil {
59+ editor.UseRemote(session)
60+ }
61+ // …
62+ if !opts.noLSP && session == nil {
63+ editor.StartLanguageServer(ctx, app.ProjectRoot(p, opts.files))
64+ }
65+ ```
66+
67+7. Build, then try it against the demo machine in `turbo-editors/demo-remote-machine` (its README says how to start it):
68+
69+ ```bash
70+ go build -o bin/turbo-x . && ./bin/turbo-x -ssh turbo-demo:workspace/hello-python
71+ ```
72+
73+## Variants
74+
75+- **Testing the flag without a machine.** Split `parseFlags` into a function taking a `*flag.FlagSet` and the arguments, and give it `flag.NewFlagSet(name, flag.ContinueOnError)` in a test. A bad target such as `-oProxyCommand=x` is refused by `connectRemote` before anything runs, so it can be tested with no ssh at all.
76+- **Testing file access without a machine.** `fsys.Use(fsystest.New("/remote/project"))` in a test makes every read and write go to memory; see [fsys](../reference/fsys.md).
77+- **Your editor is built against the published library.** `fsys` and `remote` arrived after v1.0.4; require the version that has them, or use a workspace — [test without publishing](test-without-publishing.md).
78+
79+## See also
80+
81+- Every function used here: [remote](../reference/remote.md), [fsys](../reference/fsys.md), [the app API](../reference/app.md)
82+- What changes in the editor, and why: [remote editing](../explanation/remote-editing.md)
modified docs/en/how-to/run-the-tests.md +11 -0
@@ -57,6 +57,17 @@ make help
5757
5858 Use `make race`, not `make test`. A data race in a constructor that starts a goroutine hid here for a whole feature, because a shell takes longer to produce output than an assignment takes to run.
5959
60+### You changed `remote`, and want a real sshd
61+
62+`go test ./remote/` needs no network: it runs a real SFTP server through pipes, and the test binary itself stands in for ssh. To run the one test that talks to a real sshd, start the demo machine in `turbo-editors/demo-remote-machine` and give the test its key:
63+
64+```bash
65+(cd ../demo-remote-machine && ./make-key.sh && docker compose up -d --build)
66+TURBO_CORE_SSH_KEY=$PWD/../demo-remote-machine/keys/id_demo go test ./remote/ -run Real -v
67+```
68+
69+`TURBO_CORE_SSH_PORT` (default `2222`) and `TURBO_CORE_SSH_TARGET` (default `bob@127.0.0.1:workspace/hello-python`) point it elsewhere. Without `TURBO_CORE_SSH_KEY` the test skips itself.
70+
6071 ### A test passed the moment you wrote it
6172
6273 Break the code it covers and watch it fail. This project has more than one recorded case of a test that passed because it tested nothing — a callback nobody wired, a step the test itself performed. A new test that has never failed has not been tested.
@@ -57,6 +57,17 @@ make help
57 57
58 Use `make race`, not `make test`. A data race in a constructor that starts a goroutine hid here for a whole feature, because a shell takes longer to produce output than an assignment takes to run.58 Use `make race`, not `make test`. A data race in a constructor that starts a goroutine hid here for a whole feature, because a shell takes longer to produce output than an assignment takes to run.
59 59
60+### You changed `remote`, and want a real sshd
61+
62+`go test ./remote/` needs no network: it runs a real SFTP server through pipes, and the test binary itself stands in for ssh. To run the one test that talks to a real sshd, start the demo machine in `turbo-editors/demo-remote-machine` and give the test its key:
63+
64+```bash
65+(cd ../demo-remote-machine && ./make-key.sh && docker compose up -d --build)
66+TURBO_CORE_SSH_KEY=$PWD/../demo-remote-machine/keys/id_demo go test ./remote/ -run Real -v
67+```
68+
69+`TURBO_CORE_SSH_PORT` (default `2222`) and `TURBO_CORE_SSH_TARGET` (default `bob@127.0.0.1:workspace/hello-python`) point it elsewhere. Without `TURBO_CORE_SSH_KEY` the test skips itself.
70+
60 ### A test passed the moment you wrote it71 ### A test passed the moment you wrote it
61 72
62 Break the code it covers and watch it fail. This project has more than one recorded case of a test that passed because it tested nothing — a callback nobody wired, a step the test itself performed. A new test that has never failed has not been tested.73 Break the code it covers and watch it fail. This project has more than one recorded case of a test that passed because it tested nothing — a callback nobody wired, a step the test itself performed. A new test that has never failed has not been tested.
modified docs/en/reference/app.md +18 -0
@@ -99,6 +99,23 @@ Saving keeps the server's set of open documents true. Writing a file the server
9999 | `Message(text string)` | A one-line note on the status bar. |
100100 | `ShowMessage(title, message string)` | A modal box. |
101101
102+## Another machine
103+
104+| Method | Description |
105+| --- | --- |
106+| `UseRemote(r Remote)` | Tells the editor its project is on another machine. Call it after `app.New`, having already switched `fsys` to that machine. From then on terminal windows and tools run through `r`, the status bar starts with `⇄ <r.Name()>`, and agent windows are refused with a message. |
107+| `Remote() Remote` | The machine being edited, or nil when the files are local. |
108+
109+`Remote` is an interface; `*remote.Session` implements it.
110+
111+| `Remote` method | Used for |
112+| --- | --- |
113+| `Name() string` | The status bar, and a terminal window's title until the shell names itself. |
114+| `ShellCommand(dir string) []string` | The argv of `New terminal` (F8): a login shell in `dir`, the directory of the front file or the project. Started from the editor's own local working directory. |
115+| `RunCommand(dir, command string, withTerminal bool) []string` | The argv of a tool: `withTerminal` true for `output = "terminal"`, false for `popup` and `editor`, which go through `tools.StartProgram`. `dir` is the project directory. |
116+
117+What `UseRemote` does not do: start or stop a language server (the command decides; see [add remote editing](../how-to/add-remote-editing.md)), or change where themes and the user's snippets are read from.
118+
102119 ## Tool parameters
103120
104121 A command may ask for values before it runs, by writing `{{label}}` where the value goes. `app.RunTool` opens a box for them; nothing here draws anything.
@@ -131,3 +148,4 @@ A command may ask for values before it runs, by writing `{{label}}` where the va
131148
132149 - [Build an editor](../tutorials/build-an-editor.md)
133150 - [Profile reference](profile.md)
151+- [remote](remote.md), [fsys](fsys.md)
@@ -99,6 +99,23 @@ Saving keeps the server's set of open documents true. Writing a file the server
99 | `Message(text string)` | A one-line note on the status bar. |99 | `Message(text string)` | A one-line note on the status bar. |
100 | `ShowMessage(title, message string)` | A modal box. |100 | `ShowMessage(title, message string)` | A modal box. |
101 101
102+## Another machine
103+
104+| Method | Description |
105+| --- | --- |
106+| `UseRemote(r Remote)` | Tells the editor its project is on another machine. Call it after `app.New`, having already switched `fsys` to that machine. From then on terminal windows and tools run through `r`, the status bar starts with `⇄ <r.Name()>`, and agent windows are refused with a message. |
107+| `Remote() Remote` | The machine being edited, or nil when the files are local. |
108+
109+`Remote` is an interface; `*remote.Session` implements it.
110+
111+| `Remote` method | Used for |
112+| --- | --- |
113+| `Name() string` | The status bar, and a terminal window's title until the shell names itself. |
114+| `ShellCommand(dir string) []string` | The argv of `New terminal` (F8): a login shell in `dir`, the directory of the front file or the project. Started from the editor's own local working directory. |
115+| `RunCommand(dir, command string, withTerminal bool) []string` | The argv of a tool: `withTerminal` true for `output = "terminal"`, false for `popup` and `editor`, which go through `tools.StartProgram`. `dir` is the project directory. |
116+
117+What `UseRemote` does not do: start or stop a language server (the command decides; see [add remote editing](../how-to/add-remote-editing.md)), or change where themes and the user's snippets are read from.
118+
102 ## Tool parameters119 ## Tool parameters
103 120
104 A command may ask for values before it runs, by writing `{{label}}` where the value goes. `app.RunTool` opens a box for them; nothing here draws anything.121 A command may ask for values before it runs, by writing `{{label}}` where the value goes. `app.RunTool` opens a box for them; nothing here draws anything.
@@ -131,3 +148,4 @@ A command may ask for values before it runs, by writing `{{label}}` where the va
131 148
132 - [Build an editor](../tutorials/build-an-editor.md)149 - [Build an editor](../tutorials/build-an-editor.md)
133 - [Profile reference](profile.md)150 - [Profile reference](profile.md)
151+- [remote](remote.md), [fsys](fsys.md)
added docs/en/reference/fsys.md +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+# Reference: fsys
2+
3+> Neutral description of package `fsys`, the one door through which the editor reaches the files of the project it edits, and of `fsys/fsystest`, its in-memory stand-in for tests.
4+
5+Import path: `rickub.com/turbo-editors/turbo-core/fsys`. Depends on the standard library only.
6+
7+## What goes through it
8+
9+| Goes through `fsys` | Stays on the local disk (package `os`) |
10+| --- | --- |
11+| Source files opened, reloaded and saved (`buffer`) | The user's themes (`theme`) |
12+| The project tree (`filetree`) | The user's own snippets file (`snippets.UserPath`) |
13+| The Open and Save As dialogs (`app`) | The language server's executable (`lsp`) |
14+| `.turbo-<slug>/settings.toml`, `snippets.toml`, `tools.toml` (`settings`, `snippets`, `tools`, `projectfile`) | The agents file and the agents' own reads and writes (`acp`) |
15+| The working directory the editor treats as the project (`app`) | `-load-config`'s clone and copy (`configrepo`) |
16+| The file watcher's once-a-second stat (`app`) | |
17+
18+## Interface `FS`
19+
20+```go
21+type FS interface {
22+ ReadFile(name string) ([]byte, error)
23+ WriteFileAtomic(name string, data []byte, perm fs.FileMode) error
24+ ReadDir(name string) ([]fs.DirEntry, error)
25+ Stat(name string) (fs.FileInfo, error)
26+ MkdirAll(name string, perm fs.FileMode) error
27+ Getwd() (string, error)
28+}
29+```
30+
31+| Method | Contract |
32+| --- | --- |
33+| `ReadFile` | The whole content of a file. |
34+| `WriteFileAtomic` | Replaces a file with `data` and mode `perm` so that a reader sees the old content or the new one, never half. The directory must exist. |
35+| `ReadDir` | A directory's entries, sorted by name. |
36+| `Stat` | Describes a file, following symbolic links. |
37+| `MkdirAll` | Makes a directory and every missing parent. |
38+| `Getwd` | The absolute directory relative paths start from. |
39+
40+Every error meaning "no such file" satisfies `errors.Is(err, fs.ErrNotExist)`. Relative paths are relative to `Getwd`.
41+
42+## Functions
43+
44+| Function | Description |
45+| --- | --- |
46+| `Use(f FS) (previous FS)` | Makes `f` the file system every package goes through; returns the one it replaces. Safe from any goroutine; meant to be called once, at start-up, before any file is opened. |
47+| `Current() FS` | The file system in use. |
48+| `IsLocal() bool` | Whether the file system in use is `Local`. |
49+| `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd` | The `FS` methods, on `Current()`. |
50+| `Abs(path string) (string, error)` | `path` made absolute against `Getwd()` and cleaned. The remote-aware counterpart of `filepath.Abs`. |
51+
52+## Type `Local`
53+
54+`Local{}` is the default: the disk of the machine the editor runs on, through package `os`. `WriteFileAtomic` writes `.<name>.<random>` beside the target, sets `perm`, and renames it over the target; the temporary file is removed on every failure.
55+
56+## Package `fsystest`
57+
58+Import path: `rickub.com/turbo-editors/turbo-core/fsys/fsystest`. For tests only.
59+
60+| Function / method | Description |
61+| --- | --- |
62+| `New(working string) *Memory` | An empty in-memory `FS` whose working directory, and every directory above it, exists. Safe for concurrent use. |
63+| `(*Memory) AddFile(name, content string)` | Creates or replaces a file with mode `0644`, making its directories. |
64+| `(*Memory) File(name string) (string, bool)` | A file's content, and whether it exists. |
65+| `(*Memory) Mode(name string) fs.FileMode` | A file's permissions, `0` when absent. |
66+
67+Each write moves the file's modification time on by one nanosecond, so a watcher comparing stamps sees every change.
68+
69+## Example
70+
71+```go
72+memory := fsystest.New("/remote/project")
73+memory.AddFile("/remote/project/hello.py", "print('hello')\n")
74+
75+previous := fsys.Use(memory)
76+defer fsys.Use(previous)
77+
78+data, _ := fsys.ReadFile("hello.py") // "print('hello')\n"
79+```
80+
81+## See also
82+
83+- The SFTP implementation of `FS`: [remote](remote.md)
84+- Why the files have one door: [remote editing](../explanation/remote-editing.md)
new file mode 100644
@@ -0,0 +1,84 @@
1+# Reference: fsys
2+
3+> Neutral description of package `fsys`, the one door through which the editor reaches the files of the project it edits, and of `fsys/fsystest`, its in-memory stand-in for tests.
4+
5+Import path: `rickub.com/turbo-editors/turbo-core/fsys`. Depends on the standard library only.
6+
7+## What goes through it
8+
9+| Goes through `fsys` | Stays on the local disk (package `os`) |
10+| --- | --- |
11+| Source files opened, reloaded and saved (`buffer`) | The user's themes (`theme`) |
12+| The project tree (`filetree`) | The user's own snippets file (`snippets.UserPath`) |
13+| The Open and Save As dialogs (`app`) | The language server's executable (`lsp`) |
14+| `.turbo-<slug>/settings.toml`, `snippets.toml`, `tools.toml` (`settings`, `snippets`, `tools`, `projectfile`) | The agents file and the agents' own reads and writes (`acp`) |
15+| The working directory the editor treats as the project (`app`) | `-load-config`'s clone and copy (`configrepo`) |
16+| The file watcher's once-a-second stat (`app`) | |
17+
18+## Interface `FS`
19+
20+```go
21+type FS interface {
22+ ReadFile(name string) ([]byte, error)
23+ WriteFileAtomic(name string, data []byte, perm fs.FileMode) error
24+ ReadDir(name string) ([]fs.DirEntry, error)
25+ Stat(name string) (fs.FileInfo, error)
26+ MkdirAll(name string, perm fs.FileMode) error
27+ Getwd() (string, error)
28+}
29+```
30+
31+| Method | Contract |
32+| --- | --- |
33+| `ReadFile` | The whole content of a file. |
34+| `WriteFileAtomic` | Replaces a file with `data` and mode `perm` so that a reader sees the old content or the new one, never half. The directory must exist. |
35+| `ReadDir` | A directory's entries, sorted by name. |
36+| `Stat` | Describes a file, following symbolic links. |
37+| `MkdirAll` | Makes a directory and every missing parent. |
38+| `Getwd` | The absolute directory relative paths start from. |
39+
40+Every error meaning "no such file" satisfies `errors.Is(err, fs.ErrNotExist)`. Relative paths are relative to `Getwd`.
41+
42+## Functions
43+
44+| Function | Description |
45+| --- | --- |
46+| `Use(f FS) (previous FS)` | Makes `f` the file system every package goes through; returns the one it replaces. Safe from any goroutine; meant to be called once, at start-up, before any file is opened. |
47+| `Current() FS` | The file system in use. |
48+| `IsLocal() bool` | Whether the file system in use is `Local`. |
49+| `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd` | The `FS` methods, on `Current()`. |
50+| `Abs(path string) (string, error)` | `path` made absolute against `Getwd()` and cleaned. The remote-aware counterpart of `filepath.Abs`. |
51+
52+## Type `Local`
53+
54+`Local{}` is the default: the disk of the machine the editor runs on, through package `os`. `WriteFileAtomic` writes `.<name>.<random>` beside the target, sets `perm`, and renames it over the target; the temporary file is removed on every failure.
55+
56+## Package `fsystest`
57+
58+Import path: `rickub.com/turbo-editors/turbo-core/fsys/fsystest`. For tests only.
59+
60+| Function / method | Description |
61+| --- | --- |
62+| `New(working string) *Memory` | An empty in-memory `FS` whose working directory, and every directory above it, exists. Safe for concurrent use. |
63+| `(*Memory) AddFile(name, content string)` | Creates or replaces a file with mode `0644`, making its directories. |
64+| `(*Memory) File(name string) (string, bool)` | A file's content, and whether it exists. |
65+| `(*Memory) Mode(name string) fs.FileMode` | A file's permissions, `0` when absent. |
66+
67+Each write moves the file's modification time on by one nanosecond, so a watcher comparing stamps sees every change.
68+
69+## Example
70+
71+```go
72+memory := fsystest.New("/remote/project")
73+memory.AddFile("/remote/project/hello.py", "print('hello')\n")
74+
75+previous := fsys.Use(memory)
76+defer fsys.Use(previous)
77+
78+data, _ := fsys.ReadFile("hello.py") // "print('hello')\n"
79+```
80+
81+## See also
82+
83+- The SFTP implementation of `FS`: [remote](remote.md)
84+- Why the files have one door: [remote editing](../explanation/remote-editing.md)
modified docs/en/reference/packages.md +23 -15
@@ -2,16 +2,19 @@
22
33 > Neutral, exhaustive description of what turbo-core holds.
44
5-Nineteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.
5+Twenty-two packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.
66
77 ```
8-app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
8+app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version, fsys}
9+ remote → github.com/pkg/sftp ← runs the ssh command; implements fsys.FS without importing fsys
910 editor → {buffer, syntax, ui, theme}
1011 terminal → {ui, theme, tcell, golang.org/x/sys}
11- filetree → {ui, theme, tcell}
12- snippets → {toml, projectfile, profile}
13- settings → {toml, projectfile, profile}
14- tools → {toml, projectfile, profile}
12+ filetree → {ui, theme, tcell, fsys}
13+ snippets → {toml, projectfile, profile, fsys}
14+ settings → {toml, projectfile, profile, fsys}
15+ tools → {toml, projectfile, profile, fsys}
16+ buffer, projectfile → fsys
17+ fsys → stdlib only
1518 configrepo → {projectfile, profile} ← runs the git command
1619 syntax → theme
1720 ui → theme
@@ -22,35 +25,39 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
2225 | Package | What it holds | Depends on |
2326 | --- | --- | --- |
2427 | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** |
25-| `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** |
26-| `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** |
28+| `fsys` | The one door to the project's files: the `FS` interface, the local disk, and `Use` to switch every package to another machine | **stdlib only** |
29+| `fsys/fsystest` | An in-memory `fsys.FS`, for tests | `fsys` |
30+| `remote` | Another machine over the `ssh` command: a ControlMaster connection, the files over SFTP, the command lines of remote shells and tools (`-ssh`) | `github.com/pkg/sftp`, the `ssh` command; its `FS` satisfies `fsys.FS` without importing `fsys` |
31+| `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | `fsys` |
32+| `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | `fsys` |
2733 | `configrepo` | A shared configuration directory fetched from a forge URL (rickub, GitHub, GitLab, Codeberg) by a shallow git clone, for an editor's `-load-config` | `projectfile`, `profile`, the `git` command |
2834 | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** |
2935 | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** |
3036 | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` |
3137 | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own; two kinds of inheritance | `tcell`, `toml` |
3238 | `syntax` | Colouring: nine built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` |
33-| `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile` |
34-| `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile` |
35-| `tools` | The commands a project runs on itself, and which menu each is in | `toml`, `projectfile`, `profile` |
39+| `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile`, `fsys` |
40+| `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile`, `fsys` |
41+| `tools` | The commands a project runs on itself, and which menu each is in; `Start` through the local shell, `StartProgram` for an argv such as a remote one | `toml`, `projectfile`, `profile`, `fsys` |
3642 | `ui` | Turbo Vision widgets: painter, desktop, window, menu, dialog, controls, status bar | `theme`, `tcell` |
3743 | `editor` | The editing widget: viewport, scrolling, keys, mouse, snippet insertion | `buffer`, `syntax`, `theme`, `ui` |
3844 | `terminal` | A shell in a window: pseudo-terminal, VT/ANSI emulator, screen grid, the widget | `theme`, `ui`, `tcell`, `golang.org/x/sys` |
39-| `filetree` | A project's files as an expandable tree, and the window showing it | `theme`, `ui`, `tcell` |
45+| `filetree` | A project's files as an expandable tree, and the window showing it | `theme`, `ui`, `tcell`, `fsys` |
4046 | `acp` | A coding agent in a window: the Agent Client Protocol, the conversation, the widget | `jsonrpc`, `profile`, `projectfile`, `syntax`, `theme`, `ui`, `tcell`, `toml` |
41-| `app` | Assembly: menus, dialogs, event routing, completion popup, language-server lifecycle | all of the above |
47+| `app` | Assembly: menus, dialogs, event routing, completion popup, language-server lifecycle, remote shells and tools (`UseRemote`) | all of the above except `remote`, which it knows through the `app.Remote` interface |
4248
4349 ## Third-party dependencies
4450
45-Three, and only three:
51+Four direct ones:
4652
4753 | Module | Why |
4854 | --- | --- |
4955 | `github.com/gdamore/tcell/v2` | the terminal |
5056 | `github.com/BurntSushi/toml` | every configuration file the editor reads |
5157 | `golang.org/x/sys` | the pseudo-terminal ioctls on Linux and macOS; the pseudo-console, process and job-object calls on Windows |
58+| `github.com/pkg/sftp` | the SFTP client `remote` reads and writes a machine's files with, over the channel ssh opens; it brings `github.com/kr/fs` and `golang.org/x/crypto` in as indirect requirements |
5259
53-The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written.
60+The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written. SSH itself is not in the binary: `remote` runs the installed `ssh` command.
5461
5562 ## The diagram
5663
@@ -59,3 +66,4 @@ The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are
5966 ## See also
6067
6168 - Why the graph is shaped this way: [architecture](../explanation/architecture.md)
69+- The two packages of remote editing: [fsys](fsys.md), [remote](remote.md)
@@ -2,16 +2,19 @@
2 2
3 > Neutral, exhaustive description of what turbo-core holds.3 > Neutral, exhaustive description of what turbo-core holds.
4 4
5-Nineteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.5+Twenty-two packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one.
6 6
7 ```7 ```
8-app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}8+app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version, fsys}
9+ remote → github.com/pkg/sftp ← runs the ssh command; implements fsys.FS without importing fsys
9 editor → {buffer, syntax, ui, theme}10 editor → {buffer, syntax, ui, theme}
10 terminal → {ui, theme, tcell, golang.org/x/sys}11 terminal → {ui, theme, tcell, golang.org/x/sys}
11- filetree → {ui, theme, tcell}12+ filetree → {ui, theme, tcell, fsys}
12- snippets → {toml, projectfile, profile}13+ snippets → {toml, projectfile, profile, fsys}
13- settings → {toml, projectfile, profile}14+ settings → {toml, projectfile, profile, fsys}
14- tools → {toml, projectfile, profile}15+ tools → {toml, projectfile, profile, fsys}
16+ buffer, projectfile → fsys
17+ fsys → stdlib only
15 configrepo → {projectfile, profile} ← runs the git command18 configrepo → {projectfile, profile} ← runs the git command
16 syntax → theme19 syntax → theme
17 ui → theme20 ui → theme
@@ -22,35 +25,39 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
22 | Package | What it holds | Depends on |25 | Package | What it holds | Depends on |
23 | --- | --- | --- |26 | --- | --- | --- |
24 | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** |27 | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** |
25-| `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** |28+| `fsys` | The one door to the project's files: the `FS` interface, the local disk, and `Use` to switch every package to another machine | **stdlib only** |
26-| `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** |29+| `fsys/fsystest` | An in-memory `fsys.FS`, for tests | `fsys` |
30+| `remote` | Another machine over the `ssh` command: a ControlMaster connection, the files over SFTP, the command lines of remote shells and tools (`-ssh`) | `github.com/pkg/sftp`, the `ssh` command; its `FS` satisfies `fsys.FS` without importing `fsys` |
31+| `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | `fsys` |
32+| `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | `fsys` |
27 | `configrepo` | A shared configuration directory fetched from a forge URL (rickub, GitHub, GitLab, Codeberg) by a shallow git clone, for an editor's `-load-config` | `projectfile`, `profile`, the `git` command |33 | `configrepo` | A shared configuration directory fetched from a forge URL (rickub, GitHub, GitLab, Codeberg) by a shallow git clone, for an editor's `-load-config` | `projectfile`, `profile`, the `git` command |
28 | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** |34 | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** |
29 | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** |35 | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** |
30 | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` |36 | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` |
31 | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own; two kinds of inheritance | `tcell`, `toml` |37 | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own; two kinds of inheritance | `tcell`, `toml` |
32 | `syntax` | Colouring: nine built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` |38 | `syntax` | Colouring: nine built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` |
33-| `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile` |39+| `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile`, `fsys` |
34-| `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile` |40+| `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile`, `fsys` |
35-| `tools` | The commands a project runs on itself, and which menu each is in | `toml`, `projectfile`, `profile` |41+| `tools` | The commands a project runs on itself, and which menu each is in; `Start` through the local shell, `StartProgram` for an argv such as a remote one | `toml`, `projectfile`, `profile`, `fsys` |
36 | `ui` | Turbo Vision widgets: painter, desktop, window, menu, dialog, controls, status bar | `theme`, `tcell` |42 | `ui` | Turbo Vision widgets: painter, desktop, window, menu, dialog, controls, status bar | `theme`, `tcell` |
37 | `editor` | The editing widget: viewport, scrolling, keys, mouse, snippet insertion | `buffer`, `syntax`, `theme`, `ui` |43 | `editor` | The editing widget: viewport, scrolling, keys, mouse, snippet insertion | `buffer`, `syntax`, `theme`, `ui` |
38 | `terminal` | A shell in a window: pseudo-terminal, VT/ANSI emulator, screen grid, the widget | `theme`, `ui`, `tcell`, `golang.org/x/sys` |44 | `terminal` | A shell in a window: pseudo-terminal, VT/ANSI emulator, screen grid, the widget | `theme`, `ui`, `tcell`, `golang.org/x/sys` |
39-| `filetree` | A project's files as an expandable tree, and the window showing it | `theme`, `ui`, `tcell` |45+| `filetree` | A project's files as an expandable tree, and the window showing it | `theme`, `ui`, `tcell`, `fsys` |
40 | `acp` | A coding agent in a window: the Agent Client Protocol, the conversation, the widget | `jsonrpc`, `profile`, `projectfile`, `syntax`, `theme`, `ui`, `tcell`, `toml` |46 | `acp` | A coding agent in a window: the Agent Client Protocol, the conversation, the widget | `jsonrpc`, `profile`, `projectfile`, `syntax`, `theme`, `ui`, `tcell`, `toml` |
41-| `app` | Assembly: menus, dialogs, event routing, completion popup, language-server lifecycle | all of the above |47+| `app` | Assembly: menus, dialogs, event routing, completion popup, language-server lifecycle, remote shells and tools (`UseRemote`) | all of the above except `remote`, which it knows through the `app.Remote` interface |
42 48
43 ## Third-party dependencies49 ## Third-party dependencies
44 50
45-Three, and only three:51+Four direct ones:
46 52
47 | Module | Why |53 | Module | Why |
48 | --- | --- |54 | --- | --- |
49 | `github.com/gdamore/tcell/v2` | the terminal |55 | `github.com/gdamore/tcell/v2` | the terminal |
50 | `github.com/BurntSushi/toml` | every configuration file the editor reads |56 | `github.com/BurntSushi/toml` | every configuration file the editor reads |
51 | `golang.org/x/sys` | the pseudo-terminal ioctls on Linux and macOS; the pseudo-console, process and job-object calls on Windows |57 | `golang.org/x/sys` | the pseudo-terminal ioctls on Linux and macOS; the pseudo-console, process and job-object calls on Windows |
58+| `github.com/pkg/sftp` | the SFTP client `remote` reads and writes a machine's files with, over the channel ssh opens; it brings `github.com/kr/fs` and `golang.org/x/crypto` in as indirect requirements |
52 59
53-The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written.60+The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are hand-written. SSH itself is not in the binary: `remote` runs the installed `ssh` command.
54 61
55 ## The diagram62 ## The diagram
56 63
@@ -59,3 +66,4 @@ The tokeniser, the JSON-RPC client, the LSP framing and the VT/ANSI emulator are
59 ## See also66 ## See also
60 67
61 - Why the graph is shaped this way: [architecture](../explanation/architecture.md)68 - Why the graph is shaped this way: [architecture](../explanation/architecture.md)
69+- The two packages of remote editing: [fsys](fsys.md), [remote](remote.md)
added docs/en/reference/remote.md +115 -0
new file mode 100644
@@ -0,0 +1,115 @@
1+# Reference: remote
2+
3+> Neutral description of package `remote`, which reaches another machine through the installed `ssh` command: a shared connection, the files over SFTP, and the command lines of remote shells and tools.
4+
5+Import path: `rickub.com/turbo-editors/turbo-core/remote`. Depends on `github.com/pkg/sftp`, and on the `ssh` command at run time. Its `FS` satisfies `fsys.FS` without importing `fsys`.
6+
7+## Type `Target`
8+
9+What `-ssh` names: `[user@]host[:path]`, the way `scp` writes a remote location.
10+
11+| Field | Description |
12+| --- | --- |
13+| `User` | Who to log in as. Empty leaves it to ssh (`User` in `~/.ssh/config`, else the local user name). |
14+| `Host` | A name, an address, or a `Host` alias from `~/.ssh/config`. An IPv6 address is written in brackets: `bob@[2001:db8::1]:/srv`. |
15+| `Path` | The directory to open. Empty or `~` is the remote home; `~/x` and a relative path are relative to it. |
16+
17+| Function / method | Description |
18+| --- | --- |
19+| `ParseTarget(argument string) (Target, error)` | Reads `[user@]host[:path]`. The user is everything before the **last** `@`. |
20+| `(Target) Destination() string` | `user@host`, or `host` when no user was named: what ssh is given. |
21+| `(Target) String() string` | The target written back as it is typed. |
22+
23+`ParseTarget` returns an error wrapping `ErrBadTarget` for: an empty host or user, a host or user starting with `-` (ssh would read it as an option), a space, tab, newline or `/` in the host or user, and an unclosed or misplaced `[…]`.
24+
25+There is no port in the syntax; a port goes in `~/.ssh/config` under a `Host` alias.
26+
27+## Connecting
28+
29+| Function | Description |
30+| --- | --- |
31+| `Open(ctx, argument string) (*Session, error)` | `ParseTarget`, then prints `Connecting to <target>…` on standard error, then `Connect` with the default `Options`. What an editor's `-ssh` flag calls. |
32+| `Connect(ctx, target Target, options Options) (*Session, error)` | Logs in, opens the file channel, resolves the directory. Must be called before the editor takes the terminal. |
33+
34+`Options`:
35+
36+| Field | Default | Description |
37+| --- | --- | --- |
38+| `Program` | `"ssh"` on `PATH` | The ssh command to run. |
39+
40+What `Connect` runs, in order:
41+
42+| Step | Command | Notes |
43+| --- | --- | --- |
44+| Log in | `ssh -o ControlMaster=yes -o ControlPath=<socket> -o ControlPersist=60 -N -f -- <destination>` | In the foreground, on the terminal: this is where ssh asks for a password, a passphrase, or whether to trust a new host key. Returns once logged in; the connection stays behind the socket. |
45+| Files | `ssh -S <socket> -o ControlMaster=no -o BatchMode=yes -s -- <destination> sftp` | One SFTP channel, open until `Close`. |
46+| Directory | SFTP `realpath` then `stat` of `Path` | Must be an existing directory. |
47+
48+The socket is `<tmp>/turbo-ssh-XXXX/control`, where `<tmp>` is `os.TempDir()`, or `/tmp` when that is longer than 60 bytes (a Unix socket path is limited to about 100).
49+
50+## Type `Session`
51+
52+| Method | Description |
53+| --- | --- |
54+| `FS() *FS` | The remote files, rooted at the directory being edited. What the command passes to `fsys.Use`. |
55+| `Target() Target` | What was connected to. |
56+| `Name() string` | `Destination()`: how the machine is shown. |
57+| `ShellCommand(dir string) []string` | The argv of an interactive login shell in `dir`: `ssh -S … -t -- <destination> "exec /bin/sh -c 'cd <dir> && exec \"${SHELL:-/bin/sh}\" -l'"`. |
58+| `RunCommand(dir, command string, withTerminal bool) []string` | The argv running one command line with `/bin/sh` in `dir`: `ssh -S … -t|-T -- <destination> "exec /bin/sh -c 'cd <dir> && <command>'"`. `-t` when `withTerminal`, else `-T`. |
59+| `Close() error` | Closes the file channel, runs `ssh -S … -O exit -- <destination>`, removes the socket's directory. Terminals and tools running over the connection end with it. Safe to call twice. |
60+
61+`Session` implements `app.Remote`.
62+
63+Every command after the login passes `ControlMaster=no` and `BatchMode=yes`: it never opens a connection of its own and never prompts, because the editor owns the terminal by then.
64+
65+## Type `FS`
66+
67+The remote files over SFTP; implements `fsys.FS`. Paths are slash-separated (`path`, not `path/filepath`), relative to the directory being edited.
68+
69+| Method | SFTP requests |
70+| --- | --- |
71+| `ReadFile` | `open`, `read`…, `close` |
72+| `WriteFileAtomic` | `open` of `.<name>.<random>` with `O_EXCL`, `write`…, `close`, `setstat` (mode), `posix-rename@openssh.com` over the target; `remove` of the temporary on failure |
73+| `ReadDir` | `opendir`, `readdir`…; sorted by name |
74+| `Stat` | `stat` |
75+| `MkdirAll` | `stat` / `mkdir` per level; the remote umask decides the mode |
76+| `Getwd` | none: the directory resolved at `Connect` |
77+
78+A server's "no such file" status is wrapped so that `errors.Is(err, fs.ErrNotExist)` holds.
79+
80+## Errors
81+
82+| Error | Cause |
83+| --- | --- |
84+| `ErrBadTarget` | The `-ssh` argument is not `[user@]host[:path]`, or would be read by ssh as an option. |
85+| `ErrNoSSH` | No ssh command on `PATH`. |
86+| `ErrUnsupported` | Windows: its OpenSSH port has no ControlMaster. |
87+| `ssh <destination>: exit status 255` | The login failed; ssh has already said why on the terminal. |
88+| `opening the file channel to <destination>: … (<what ssh said>)` | The server refused the `sftp` subsystem. |
89+| `finding <path> on the remote machine: …` / `opening … : …` | The directory does not exist. |
90+| `<path> on the remote machine is not a directory: invalid argument` | The path names a file. |
91+
92+## Related: `tools.StartProgram`
93+
94+```go
95+func StartProgram(command string, argv []string, onLine func()) (*tools.Run, error)
96+```
97+
98+Runs `argv` with no local shell in between; `command` is what the run is called (`Run.Command()`, the dialog title). Output merged, stoppable with `Stop`, as `tools.Start`. An empty `argv` is an error. This is how a captured tool runs over ssh: `tools.StartProgram(command, session.RunCommand(dir, command, false), wake)`.
99+
100+## Example
101+
102+```go
103+session, err := remote.Open(ctx, "bob@build-box:/srv/app")
104+if err != nil {
105+ return err
106+}
107+defer session.Close()
108+fsys.Use(session.FS())
109+```
110+
111+## See also
112+
113+- The interface it implements: [fsys](fsys.md)
114+- Wiring it into an editor: [add remote editing](../how-to/add-remote-editing.md)
115+- Why it drives the ssh command: [remote editing](../explanation/remote-editing.md)
new file mode 100644
@@ -0,0 +1,115 @@
1+# Reference: remote
2+
3+> Neutral description of package `remote`, which reaches another machine through the installed `ssh` command: a shared connection, the files over SFTP, and the command lines of remote shells and tools.
4+
5+Import path: `rickub.com/turbo-editors/turbo-core/remote`. Depends on `github.com/pkg/sftp`, and on the `ssh` command at run time. Its `FS` satisfies `fsys.FS` without importing `fsys`.
6+
7+## Type `Target`
8+
9+What `-ssh` names: `[user@]host[:path]`, the way `scp` writes a remote location.
10+
11+| Field | Description |
12+| --- | --- |
13+| `User` | Who to log in as. Empty leaves it to ssh (`User` in `~/.ssh/config`, else the local user name). |
14+| `Host` | A name, an address, or a `Host` alias from `~/.ssh/config`. An IPv6 address is written in brackets: `bob@[2001:db8::1]:/srv`. |
15+| `Path` | The directory to open. Empty or `~` is the remote home; `~/x` and a relative path are relative to it. |
16+
17+| Function / method | Description |
18+| --- | --- |
19+| `ParseTarget(argument string) (Target, error)` | Reads `[user@]host[:path]`. The user is everything before the **last** `@`. |
20+| `(Target) Destination() string` | `user@host`, or `host` when no user was named: what ssh is given. |
21+| `(Target) String() string` | The target written back as it is typed. |
22+
23+`ParseTarget` returns an error wrapping `ErrBadTarget` for: an empty host or user, a host or user starting with `-` (ssh would read it as an option), a space, tab, newline or `/` in the host or user, and an unclosed or misplaced `[…]`.
24+
25+There is no port in the syntax; a port goes in `~/.ssh/config` under a `Host` alias.
26+
27+## Connecting
28+
29+| Function | Description |
30+| --- | --- |
31+| `Open(ctx, argument string) (*Session, error)` | `ParseTarget`, then prints `Connecting to <target>…` on standard error, then `Connect` with the default `Options`. What an editor's `-ssh` flag calls. |
32+| `Connect(ctx, target Target, options Options) (*Session, error)` | Logs in, opens the file channel, resolves the directory. Must be called before the editor takes the terminal. |
33+
34+`Options`:
35+
36+| Field | Default | Description |
37+| --- | --- | --- |
38+| `Program` | `"ssh"` on `PATH` | The ssh command to run. |
39+
40+What `Connect` runs, in order:
41+
42+| Step | Command | Notes |
43+| --- | --- | --- |
44+| Log in | `ssh -o ControlMaster=yes -o ControlPath=<socket> -o ControlPersist=60 -N -f -- <destination>` | In the foreground, on the terminal: this is where ssh asks for a password, a passphrase, or whether to trust a new host key. Returns once logged in; the connection stays behind the socket. |
45+| Files | `ssh -S <socket> -o ControlMaster=no -o BatchMode=yes -s -- <destination> sftp` | One SFTP channel, open until `Close`. |
46+| Directory | SFTP `realpath` then `stat` of `Path` | Must be an existing directory. |
47+
48+The socket is `<tmp>/turbo-ssh-XXXX/control`, where `<tmp>` is `os.TempDir()`, or `/tmp` when that is longer than 60 bytes (a Unix socket path is limited to about 100).
49+
50+## Type `Session`
51+
52+| Method | Description |
53+| --- | --- |
54+| `FS() *FS` | The remote files, rooted at the directory being edited. What the command passes to `fsys.Use`. |
55+| `Target() Target` | What was connected to. |
56+| `Name() string` | `Destination()`: how the machine is shown. |
57+| `ShellCommand(dir string) []string` | The argv of an interactive login shell in `dir`: `ssh -S … -t -- <destination> "exec /bin/sh -c 'cd <dir> && exec \"${SHELL:-/bin/sh}\" -l'"`. |
58+| `RunCommand(dir, command string, withTerminal bool) []string` | The argv running one command line with `/bin/sh` in `dir`: `ssh -S … -t|-T -- <destination> "exec /bin/sh -c 'cd <dir> && <command>'"`. `-t` when `withTerminal`, else `-T`. |
59+| `Close() error` | Closes the file channel, runs `ssh -S … -O exit -- <destination>`, removes the socket's directory. Terminals and tools running over the connection end with it. Safe to call twice. |
60+
61+`Session` implements `app.Remote`.
62+
63+Every command after the login passes `ControlMaster=no` and `BatchMode=yes`: it never opens a connection of its own and never prompts, because the editor owns the terminal by then.
64+
65+## Type `FS`
66+
67+The remote files over SFTP; implements `fsys.FS`. Paths are slash-separated (`path`, not `path/filepath`), relative to the directory being edited.
68+
69+| Method | SFTP requests |
70+| --- | --- |
71+| `ReadFile` | `open`, `read`…, `close` |
72+| `WriteFileAtomic` | `open` of `.<name>.<random>` with `O_EXCL`, `write`…, `close`, `setstat` (mode), `posix-rename@openssh.com` over the target; `remove` of the temporary on failure |
73+| `ReadDir` | `opendir`, `readdir`…; sorted by name |
74+| `Stat` | `stat` |
75+| `MkdirAll` | `stat` / `mkdir` per level; the remote umask decides the mode |
76+| `Getwd` | none: the directory resolved at `Connect` |
77+
78+A server's "no such file" status is wrapped so that `errors.Is(err, fs.ErrNotExist)` holds.
79+
80+## Errors
81+
82+| Error | Cause |
83+| --- | --- |
84+| `ErrBadTarget` | The `-ssh` argument is not `[user@]host[:path]`, or would be read by ssh as an option. |
85+| `ErrNoSSH` | No ssh command on `PATH`. |
86+| `ErrUnsupported` | Windows: its OpenSSH port has no ControlMaster. |
87+| `ssh <destination>: exit status 255` | The login failed; ssh has already said why on the terminal. |
88+| `opening the file channel to <destination>: … (<what ssh said>)` | The server refused the `sftp` subsystem. |
89+| `finding <path> on the remote machine: …` / `opening … : …` | The directory does not exist. |
90+| `<path> on the remote machine is not a directory: invalid argument` | The path names a file. |
91+
92+## Related: `tools.StartProgram`
93+
94+```go
95+func StartProgram(command string, argv []string, onLine func()) (*tools.Run, error)
96+```
97+
98+Runs `argv` with no local shell in between; `command` is what the run is called (`Run.Command()`, the dialog title). Output merged, stoppable with `Stop`, as `tools.Start`. An empty `argv` is an error. This is how a captured tool runs over ssh: `tools.StartProgram(command, session.RunCommand(dir, command, false), wake)`.
99+
100+## Example
101+
102+```go
103+session, err := remote.Open(ctx, "bob@build-box:/srv/app")
104+if err != nil {
105+ return err
106+}
107+defer session.Close()
108+fsys.Use(session.FS())
109+```
110+
111+## See also
112+
113+- The interface it implements: [fsys](fsys.md)
114+- Wiring it into an editor: [add remote editing](../how-to/add-remote-editing.md)
115+- Why it drives the ssh command: [remote editing](../explanation/remote-editing.md)
modified docs/fr/README.md +4 -0
@@ -13,6 +13,7 @@ Cette documentation suit [Diátaxis](https://diataxis.fr) : quatre sortes de pag
1313 - [Ajouter un langage](how-to/add-a-language.md) — écrire un analyseur et l'enregistrer.
1414 - [Dialoguer avec un serveur de langage](how-to/talk-to-a-language-server.md) — brancher l'éditeur sur gopls, rust-analyzer, ou tout autre serveur LSP.
1515 - [Écrire les fichiers de départ](how-to/write-the-starter-files.md) — les modèles de réglages, d'extraits et d'outils que votre éditeur propose de créer.
16+- [Ajouter l'édition à distance](how-to/add-remote-editing.md) — donner à un éditeur `-ssh user@machine[:dir]`, pour éditer les fichiers d'une autre machine.
1617 - [Lancer les tests](how-to/run-the-tests.md) — la commande unique, et ce qu'elle couvre.
1718 - [Tester sans publier](how-to/test-without-publishing.md) — faire tourner les éditeurs sur un turbo-core modifié seulement en local.
1819 - [Publier la bibliothèque](how-to/release-the-library.md) — poser une version dont les éditeurs peuvent dépendre.
@@ -23,11 +24,14 @@ Cette documentation suit [Diátaxis](https://diataxis.fr) : quatre sortes de pag
2324 - [Paquets](reference/packages.md) — ce que contient chaque paquet, et ce dont il a le droit de dépendre.
2425 - [Le point d'extension syntaxique](reference/syntax.md) — `Definition`, `Register`, `LineScanner`, `LineIndex`.
2526 - [L'API app](reference/app.md) — ce qu'une commande peut appeler sur un éditeur en marche.
27+- [fsys](reference/fsys.md) — la porte unique vers les fichiers du projet.
28+- [remote](reference/remote.md) — une autre machine via la commande `ssh`.
2629
2730 ## [Explications](explanation/) — pourquoi c'est ainsi
2831
2932 - [Architecture](explanation/architecture.md) — la forme de la bibliothèque, et la règle qui la maintient.
3033 - [Ce qui appartient ici](explanation/what-belongs-here.md) — la frontière entre la bibliothèque et un éditeur bâti dessus, et comment elle a été tracée.
34+- [L'édition à distance](explanation/remote-editing.md) — pourquoi `-ssh` pilote la commande ssh, partage une seule connexion, et fait passer les fichiers en SFTP.
3135
3236 ## Où sont les éditeurs
3337
@@ -13,6 +13,7 @@ Cette documentation suit [Diátaxis](https://diataxis.fr) : quatre sortes de pag
13 - [Ajouter un langage](how-to/add-a-language.md) — écrire un analyseur et l'enregistrer.13 - [Ajouter un langage](how-to/add-a-language.md) — écrire un analyseur et l'enregistrer.
14 - [Dialoguer avec un serveur de langage](how-to/talk-to-a-language-server.md) — brancher l'éditeur sur gopls, rust-analyzer, ou tout autre serveur LSP.14 - [Dialoguer avec un serveur de langage](how-to/talk-to-a-language-server.md) — brancher l'éditeur sur gopls, rust-analyzer, ou tout autre serveur LSP.
15 - [Écrire les fichiers de départ](how-to/write-the-starter-files.md) — les modèles de réglages, d'extraits et d'outils que votre éditeur propose de créer.15 - [Écrire les fichiers de départ](how-to/write-the-starter-files.md) — les modèles de réglages, d'extraits et d'outils que votre éditeur propose de créer.
16+- [Ajouter l'édition à distance](how-to/add-remote-editing.md) — donner à un éditeur `-ssh user@machine[:dir]`, pour éditer les fichiers d'une autre machine.
16 - [Lancer les tests](how-to/run-the-tests.md) — la commande unique, et ce qu'elle couvre.17 - [Lancer les tests](how-to/run-the-tests.md) — la commande unique, et ce qu'elle couvre.
17 - [Tester sans publier](how-to/test-without-publishing.md) — faire tourner les éditeurs sur un turbo-core modifié seulement en local.18 - [Tester sans publier](how-to/test-without-publishing.md) — faire tourner les éditeurs sur un turbo-core modifié seulement en local.
18 - [Publier la bibliothèque](how-to/release-the-library.md) — poser une version dont les éditeurs peuvent dépendre.19 - [Publier la bibliothèque](how-to/release-the-library.md) — poser une version dont les éditeurs peuvent dépendre.
@@ -23,11 +24,14 @@ Cette documentation suit [Diátaxis](https://diataxis.fr) : quatre sortes de pag
23 - [Paquets](reference/packages.md) — ce que contient chaque paquet, et ce dont il a le droit de dépendre.24 - [Paquets](reference/packages.md) — ce que contient chaque paquet, et ce dont il a le droit de dépendre.
24 - [Le point d'extension syntaxique](reference/syntax.md) — `Definition`, `Register`, `LineScanner`, `LineIndex`.25 - [Le point d'extension syntaxique](reference/syntax.md) — `Definition`, `Register`, `LineScanner`, `LineIndex`.
25 - [L'API app](reference/app.md) — ce qu'une commande peut appeler sur un éditeur en marche.26 - [L'API app](reference/app.md) — ce qu'une commande peut appeler sur un éditeur en marche.
27+- [fsys](reference/fsys.md) — la porte unique vers les fichiers du projet.
28+- [remote](reference/remote.md) — une autre machine via la commande `ssh`.
26 29
27 ## [Explications](explanation/) — pourquoi c'est ainsi30 ## [Explications](explanation/) — pourquoi c'est ainsi
28 31
29 - [Architecture](explanation/architecture.md) — la forme de la bibliothèque, et la règle qui la maintient.32 - [Architecture](explanation/architecture.md) — la forme de la bibliothèque, et la règle qui la maintient.
30 - [Ce qui appartient ici](explanation/what-belongs-here.md) — la frontière entre la bibliothèque et un éditeur bâti dessus, et comment elle a été tracée.33 - [Ce qui appartient ici](explanation/what-belongs-here.md) — la frontière entre la bibliothèque et un éditeur bâti dessus, et comment elle a été tracée.
34+- [L'édition à distance](explanation/remote-editing.md) — pourquoi `-ssh` pilote la commande ssh, partage une seule connexion, et fait passer les fichiers en SFTP.
31 35
32 ## Où sont les éditeurs36 ## Où sont les éditeurs
33 37
modified docs/fr/explanation/architecture.md +6 -1
@@ -2,7 +2,7 @@
22
33 ## De quoi s'agit-il ?
44
5-turbo-core est un IDE de terminal avec un trou à l'endroit du langage. Dix-huit paquets mettent en œuvre tout ce que fait un éditeur — texte, annulation, fenêtres, menus, dialogues, thèmes, un émulateur VT, une arborescence de fichiers, un client LSP, un agent de code — et aucun d'eux ne sait quel langage est édité. Un éditeur bâti dessus, c'est une commande, un `profile.Profile`, et un analyseur.
5+turbo-core est un IDE de terminal avec un trou à l'endroit du langage. Vingt et un paquets mettent en œuvre tout ce que fait un éditeur — texte, annulation, fenêtres, menus, dialogues, thèmes, un émulateur VT, une arborescence de fichiers, un client LSP, un agent de code, l'édition via ssh — et aucun d'eux ne sait quel langage est édité. Un éditeur bâti dessus, c'est une commande, un `profile.Profile`, et un analyseur.
66
77 La bibliothèque n'a pas été conçue ainsi. Elle a été extraite de Turbo Go, qui avait été un unique programme de 35 000 lignes pendant quinze jours, au moment où l'on a voulu un second éditeur. La quasi-totalité a bougé sans changement ; la couture est là où il s'est avéré qu'elle devait être, pas là où quelqu'un l'avait prédit.
88
@@ -44,6 +44,10 @@ Ce n'est pas sûr depuis deux goroutines à la fois, et il n'y a aucune raison q
4444
4545 Cela a l'air incohérent et c'est délibéré. Un paquet qui recevrait un profil dont il n'utilise qu'un champ serait un paquet qui dépend de toute l'identité de l'éditeur pour répondre à une question sur un répertoire.
4646
47+## Pourquoi les fichiers du projet ont une seule porte
48+
49+Toute lecture et écriture d'un fichier *du projet* — le source, l'arborescence, le dialogue Ouvrir, les fichiers TOML du projet — passe par `fsys`, et rien d'autre dans la bibliothèque ne touche ces fichiers avec le paquet `os`. C'est ce qui permet à un éditeur lancé avec `-ssh` de remplacer le disque local par une autre machine en un seul appel, `fsys.Use`, avant toute lecture. C'est un état de paquet pour les mêmes raisons que le registre syntaxique ; l'argument complet, et ce qui a été laissé exprès sur le disque local, sont dans [l'édition à distance](remote-editing.md).
50+
4751 ## Comment cela se relie au reste
4852
4953 Les éditeurs bâtis dessus sont la preuve que la couture est réelle. Les paquets propres à Turbo Go sont passés de quatorze à un ; Turbo Rust a été bâti sur la bibliothèque sans la modifier, et les quatre défauts apparus pendant sa construction étaient tous dans son propre analyseur plutôt que dans la bibliothèque — ce qui est le résultat pour lequel l'extraction a été faite. Turbo Python, le troisième, a été bâti sur un tag publié sans la moindre modification de la bibliothèque.
@@ -52,6 +56,7 @@ Les éditeurs bâtis dessus sont la preuve que la couture est réelle. Les paque
5256
5357 - Ce que contient chaque paquet : [référence des paquets](../reference/packages.md)
5458 - Où tombe la frontière : [ce qui appartient ici](what-belongs-here.md)
59+- Éditer les fichiers d'une autre machine : [l'édition à distance](remote-editing.md)
5560
5661 ## Pourquoi jsonrpc a été extrait de lsp
5762
@@ -2,7 +2,7 @@
2 2
3 ## De quoi s'agit-il ?3 ## De quoi s'agit-il ?
4 4
5-turbo-core est un IDE de terminal avec un trou à l'endroit du langage. Dix-huit paquets mettent en œuvre tout ce que fait un éditeur — texte, annulation, fenêtres, menus, dialogues, thèmes, un émulateur VT, une arborescence de fichiers, un client LSP, un agent de code — et aucun d'eux ne sait quel langage est édité. Un éditeur bâti dessus, c'est une commande, un `profile.Profile`, et un analyseur.5+turbo-core est un IDE de terminal avec un trou à l'endroit du langage. Vingt et un paquets mettent en œuvre tout ce que fait un éditeur — texte, annulation, fenêtres, menus, dialogues, thèmes, un émulateur VT, une arborescence de fichiers, un client LSP, un agent de code, l'édition via ssh — et aucun d'eux ne sait quel langage est édité. Un éditeur bâti dessus, c'est une commande, un `profile.Profile`, et un analyseur.
6 6
7 La bibliothèque n'a pas été conçue ainsi. Elle a été extraite de Turbo Go, qui avait été un unique programme de 35 000 lignes pendant quinze jours, au moment où l'on a voulu un second éditeur. La quasi-totalité a bougé sans changement ; la couture est là où il s'est avéré qu'elle devait être, pas là où quelqu'un l'avait prédit.7 La bibliothèque n'a pas été conçue ainsi. Elle a été extraite de Turbo Go, qui avait été un unique programme de 35 000 lignes pendant quinze jours, au moment où l'on a voulu un second éditeur. La quasi-totalité a bougé sans changement ; la couture est là où il s'est avéré qu'elle devait être, pas là où quelqu'un l'avait prédit.
8 8
@@ -44,6 +44,10 @@ Ce n'est pas sûr depuis deux goroutines à la fois, et il n'y a aucune raison q
44 44
45 Cela a l'air incohérent et c'est délibéré. Un paquet qui recevrait un profil dont il n'utilise qu'un champ serait un paquet qui dépend de toute l'identité de l'éditeur pour répondre à une question sur un répertoire.45 Cela a l'air incohérent et c'est délibéré. Un paquet qui recevrait un profil dont il n'utilise qu'un champ serait un paquet qui dépend de toute l'identité de l'éditeur pour répondre à une question sur un répertoire.
46 46
47+## Pourquoi les fichiers du projet ont une seule porte
48+
49+Toute lecture et écriture d'un fichier *du projet* — le source, l'arborescence, le dialogue Ouvrir, les fichiers TOML du projet — passe par `fsys`, et rien d'autre dans la bibliothèque ne touche ces fichiers avec le paquet `os`. C'est ce qui permet à un éditeur lancé avec `-ssh` de remplacer le disque local par une autre machine en un seul appel, `fsys.Use`, avant toute lecture. C'est un état de paquet pour les mêmes raisons que le registre syntaxique ; l'argument complet, et ce qui a été laissé exprès sur le disque local, sont dans [l'édition à distance](remote-editing.md).
50+
47 ## Comment cela se relie au reste51 ## Comment cela se relie au reste
48 52
49 Les éditeurs bâtis dessus sont la preuve que la couture est réelle. Les paquets propres à Turbo Go sont passés de quatorze à un ; Turbo Rust a été bâti sur la bibliothèque sans la modifier, et les quatre défauts apparus pendant sa construction étaient tous dans son propre analyseur plutôt que dans la bibliothèque — ce qui est le résultat pour lequel l'extraction a été faite. Turbo Python, le troisième, a été bâti sur un tag publié sans la moindre modification de la bibliothèque.53 Les éditeurs bâtis dessus sont la preuve que la couture est réelle. Les paquets propres à Turbo Go sont passés de quatorze à un ; Turbo Rust a été bâti sur la bibliothèque sans la modifier, et les quatre défauts apparus pendant sa construction étaient tous dans son propre analyseur plutôt que dans la bibliothèque — ce qui est le résultat pour lequel l'extraction a été faite. Turbo Python, le troisième, a été bâti sur un tag publié sans la moindre modification de la bibliothèque.
@@ -52,6 +56,7 @@ Les éditeurs bâtis dessus sont la preuve que la couture est réelle. Les paque
52 56
53 - Ce que contient chaque paquet : [référence des paquets](../reference/packages.md)57 - Ce que contient chaque paquet : [référence des paquets](../reference/packages.md)
54 - Où tombe la frontière : [ce qui appartient ici](what-belongs-here.md)58 - Où tombe la frontière : [ce qui appartient ici](what-belongs-here.md)
59+- Éditer les fichiers d'une autre machine : [l'édition à distance](remote-editing.md)
55 60
56 ## Pourquoi jsonrpc a été extrait de lsp61 ## Pourquoi jsonrpc a été extrait de lsp
57 62
added docs/fr/explanation/remote-editing.md +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+# L'édition à distance — explication
2+
3+## De quoi s'agit-il ?
4+
5+`turbo-python -ssh bob@machine:/srv/app` édite les fichiers d'une autre machine comme s'ils étaient locaux : les fenêtres, l'arborescence du projet, le dialogue Ouvrir et les fichiers de réglages, d'extraits et d'outils du projet sont ceux de la machine distante ; une fenêtre terminal est un shell là-bas ; un outil s'exécute là-bas. Chaque éditeur l'obtient de turbo-core de la même façon — deux paquets, `fsys` et `remote`, et un appel, `app.UseRemote`.
6+
7+Cette page explique pourquoi c'est construit ainsi : pourquoi l'éditeur pilote la commande `ssh` au lieu de parler SSH lui-même, pourquoi les fichiers voyagent en SFTP, pourquoi le système de fichiers est un état de paquet, et ce qui a été laissé exprès sur la machine locale.
8+
9+## Une seule porte pour les fichiers
10+
11+Avant cette fonctionnalité, une cinquantaine d'endroits de turbo-core appelaient directement `os.ReadFile`, `os.Stat`, `os.Getwd` et leurs cousins. L'édition à distance exigeait que tous ceux qui concernent *le projet* aillent ailleurs, et qu'aucun de ceux qui concernent *la personne* ne bouge.
12+
13+Les fichiers du projet ont donc désormais une seule porte, `fsys`, et c'est un état de paquet fixé une fois au démarrage. C'est la même forme — et le même argument — que `syntax.Register` (voir [l'architecture](architecture.md)) : le choix se fait une fois par processus, avant toute lecture ; rien ne le change ensuite ; et l'alternative, faire circuler une valeur « système de fichiers » à travers `buffer`, `filetree`, `settings`, `snippets`, `tools`, `projectfile` et chaque appel de `app`, aurait changé la signature de la moitié de la bibliothèque pour une décision que personne ne prend deux fois. Le coût est celui de tout état global : un test qui le change doit le remettre, et aucun des paquets qui l'utilisent ne peut lancer en parallèle des tests qui le changent. Aucun ne le faisait.
14+
15+La frontière entre « le projet » et « la personne » passe là où elle passait déjà. Les thèmes de l'utilisateur et ses extraits personnels sont des habitudes, qui suivent la personne sur la machine qu'elle édite. L'exécutable d'un serveur de langage et un agent de code sont des programmes de cette machine-ci. `-load-config` copie dans le répertoire de travail local, là où on le tape.
16+
17+## Pourquoi la commande ssh, et pas une bibliothèque SSH
18+
19+Go a un bon client SSH, `golang.org/x/crypto/ssh`. Il a été écarté, pour la même raison que `configrepo` lance `git` au lieu de parler le HTTP intelligent : la commande installée sait déjà tout ce que la personne a configuré, et une bibliothèque n'en sait rien.
20+
21+La commande ssh lit `~/.ssh/config` — alias `Host`, ports, `User`, `IdentityFile`, `ProxyJump` via un bastion — et utilise l'agent, `known_hosts`, la passphrase d'une clé, un mot de passe, une clé matérielle. Une bibliothèque aurait dû réimplémenter chacun de ces points, un par un, et le dangereux en premier : accepter une clé d'hôte que la personne n'a jamais vue est une question dont la réponse relève de la sécurité, et OpenSSH la pose déjà correctement.
22+
23+Le coût, c'est que l'éditeur lance des processus au lieu d'appeler des fonctions. C'est pour ça que `ControlMaster` compte.
24+
25+## Pourquoi ControlMaster
26+
27+Sans lui, chaque commande ssh est une nouvelle connexion : poignée de main TCP, échange de clés, authentification — et, avec un mot de passe, une question, à chaque fois. Or l'éditeur ouvre un shell ici, lance un outil là, et fait un `stat` de chaque fichier ouvert toutes les secondes.
28+
29+`ControlMaster` fait de la première connexion le maître et laisse une socket Unix derrière elle ; chaque `ssh -S <socket>` suivant passe par cette connexion déjà authentifiée en quelques millisecondes, sans rien demander. L'éditeur se connecte donc une fois, au premier plan, **avant de prendre l'écran** — le seul moment où ssh peut poser une question sur le terminal — puis passe `BatchMode=yes` à tout ce qui suit, pour qu'un maître perdu échoue bruyamment au lieu d'afficher une demande de mot de passe par-dessus l'éditeur.
30+
31+`ControlPersist=60` garde le maître en vie une minute après son dernier utilisateur. L'éditeur tient le canal SFTP ouvert jusqu'à sa fermeture, donc cette minute ne compte qu'après un plantage : la connexion s'en va alors d'elle-même.
32+
33+Le coût, c'est Windows : son portage d'OpenSSH n'a pas de ControlMaster, et `Connect` y renvoie `ErrUnsupported`.
34+
35+## Pourquoi SFTP pour les fichiers, et pas des commandes shell
36+
37+Une fois la connexion partagée établie, la façon la moins chère en apparence de lire un fichier est `ssh … cat fichier`, et de lister un répertoire `ssh … ls`. Elle a été écartée pour deux raisons.
38+
39+La sortie de `ls` et de `stat` n'est pas portable — GNU et BSD divergent sur les options et les formats — et un surveillant de fichiers qui l'analyse casse au premier serveur macOS. Et un processus par opération, c'est un processus par fichier ouvert et par seconde rien que pour le surveillant.
40+
41+SFTP est le protocole qu'OpenSSH fournit précisément pour cela, sur un canal de la même connexion (`ssh -s … sftp`) : binaire, portable, et une requête par opération. Le client est `github.com/pkg/sftp`, la seule vraie dépendance ajoutée par la fonctionnalité. Écrire à la main la douzaine de messages SFTP dont l'éditeur a besoin a été envisagé et écarté : du code de protocole à maintenir, sans rien gagner.
42+
43+L'enregistrement atomique survit au voyage : le nouveau contenu est écrit dans un fichier temporaire à côté de la cible, puis renommé par-dessus avec `posix-rename@openssh.com`, qui remplace la cible en une étape, comme `rename(2)` en local.
44+
45+## Pourquoi toute commande distante est `/bin/sh -c '…'`
46+
47+ssh confie la commande qu'on lui donne au shell *de connexion* de l'utilisateur, qui peut être fish, ou tout autre shell qui ne lit pas `cd dir && exec …` comme sh. Toute commande envoyée est donc `exec /bin/sh -c '<script>'`, citée une fois pour le shell de connexion : la ligne de commande d'un outil signifie là-bas ce qu'elle signifie ici — `/bin/sh -c`, tubes et `&&` compris — et un terminal lance le shell de l'utilisateur avec `exec "${SHELL:-/bin/sh}" -l` depuis sh.
48+
49+## Ce qui est désactivé, et pourquoi
50+
51+- **Le serveur de langage.** Les serveurs qu'un éditeur sait trouver sont sur cette machine ; les fichiers sur lesquels on les interrogerait n'y sont pas. Lancer pylsp à distance sur la même connexion est possible, mais il faudrait alors traduire les chemins de ses réponses, et que le serveur soit installé là-bas. C'est laissé pour plus tard plutôt que fait à moitié.
52+- **Les fenêtres d'agent.** Un agent est un programme de cette machine, qui lit et écrit les fichiers de cette machine. Un agent qui tournerait pendant que l'éditeur montre le projet distant éditerait un autre projet que celui à l'écran. Le menu Agent le dit au lieu de le proposer.
53+
54+## Alternatives écartées
55+
56+- **Monter avec sshfs.** Il faut macFUSE sous macOS, une extension du noyau que la personne doit installer et approuver, et les terminaux et outils restent locaux.
57+- **Copier l'éditeur sur la machine et l'y lancer.** Le binaire est construit pour la machine de la personne — `darwin/arm64` — et le serveur est le plus souvent en `linux/amd64` ; et l'éditeur perdrait le presse-papiers local et les thèmes de la personne.
58+- **Une interface passée à travers chaque paquet** au lieu de `fsys.Use`. Voir « Une seule porte pour les fichiers » ci-dessus.
59+
60+## Comment cela se relie au reste
61+
62+`fsys` se trouve sous tout ce qui touche un fichier du projet, sans dépendance à lui. `remote` ne l'importe même pas — son `FS` a les bonnes méthodes, c'est tout ce que demande une interface Go. Il se trouve à côté de `configrepo`, puisque tous deux pilotent une commande installée, et `app` ne le connaît qu'à travers l'interface `app.Remote` à trois méthodes, si bien que ses tests utilisent une doublure qui lance `/bin/sh` en local. Chaque éditeur ajoute le flag dans son `main.go` ; la référence est [remote](../reference/remote.md), et la recette [ajouter l'édition à distance](../how-to/add-remote-editing.md).
new file mode 100644
@@ -0,0 +1,62 @@
1+# L'édition à distance — explication
2+
3+## De quoi s'agit-il ?
4+
5+`turbo-python -ssh bob@machine:/srv/app` édite les fichiers d'une autre machine comme s'ils étaient locaux : les fenêtres, l'arborescence du projet, le dialogue Ouvrir et les fichiers de réglages, d'extraits et d'outils du projet sont ceux de la machine distante ; une fenêtre terminal est un shell là-bas ; un outil s'exécute là-bas. Chaque éditeur l'obtient de turbo-core de la même façon — deux paquets, `fsys` et `remote`, et un appel, `app.UseRemote`.
6+
7+Cette page explique pourquoi c'est construit ainsi : pourquoi l'éditeur pilote la commande `ssh` au lieu de parler SSH lui-même, pourquoi les fichiers voyagent en SFTP, pourquoi le système de fichiers est un état de paquet, et ce qui a été laissé exprès sur la machine locale.
8+
9+## Une seule porte pour les fichiers
10+
11+Avant cette fonctionnalité, une cinquantaine d'endroits de turbo-core appelaient directement `os.ReadFile`, `os.Stat`, `os.Getwd` et leurs cousins. L'édition à distance exigeait que tous ceux qui concernent *le projet* aillent ailleurs, et qu'aucun de ceux qui concernent *la personne* ne bouge.
12+
13+Les fichiers du projet ont donc désormais une seule porte, `fsys`, et c'est un état de paquet fixé une fois au démarrage. C'est la même forme — et le même argument — que `syntax.Register` (voir [l'architecture](architecture.md)) : le choix se fait une fois par processus, avant toute lecture ; rien ne le change ensuite ; et l'alternative, faire circuler une valeur « système de fichiers » à travers `buffer`, `filetree`, `settings`, `snippets`, `tools`, `projectfile` et chaque appel de `app`, aurait changé la signature de la moitié de la bibliothèque pour une décision que personne ne prend deux fois. Le coût est celui de tout état global : un test qui le change doit le remettre, et aucun des paquets qui l'utilisent ne peut lancer en parallèle des tests qui le changent. Aucun ne le faisait.
14+
15+La frontière entre « le projet » et « la personne » passe là où elle passait déjà. Les thèmes de l'utilisateur et ses extraits personnels sont des habitudes, qui suivent la personne sur la machine qu'elle édite. L'exécutable d'un serveur de langage et un agent de code sont des programmes de cette machine-ci. `-load-config` copie dans le répertoire de travail local, là où on le tape.
16+
17+## Pourquoi la commande ssh, et pas une bibliothèque SSH
18+
19+Go a un bon client SSH, `golang.org/x/crypto/ssh`. Il a été écarté, pour la même raison que `configrepo` lance `git` au lieu de parler le HTTP intelligent : la commande installée sait déjà tout ce que la personne a configuré, et une bibliothèque n'en sait rien.
20+
21+La commande ssh lit `~/.ssh/config` — alias `Host`, ports, `User`, `IdentityFile`, `ProxyJump` via un bastion — et utilise l'agent, `known_hosts`, la passphrase d'une clé, un mot de passe, une clé matérielle. Une bibliothèque aurait dû réimplémenter chacun de ces points, un par un, et le dangereux en premier : accepter une clé d'hôte que la personne n'a jamais vue est une question dont la réponse relève de la sécurité, et OpenSSH la pose déjà correctement.
22+
23+Le coût, c'est que l'éditeur lance des processus au lieu d'appeler des fonctions. C'est pour ça que `ControlMaster` compte.
24+
25+## Pourquoi ControlMaster
26+
27+Sans lui, chaque commande ssh est une nouvelle connexion : poignée de main TCP, échange de clés, authentification — et, avec un mot de passe, une question, à chaque fois. Or l'éditeur ouvre un shell ici, lance un outil là, et fait un `stat` de chaque fichier ouvert toutes les secondes.
28+
29+`ControlMaster` fait de la première connexion le maître et laisse une socket Unix derrière elle ; chaque `ssh -S <socket>` suivant passe par cette connexion déjà authentifiée en quelques millisecondes, sans rien demander. L'éditeur se connecte donc une fois, au premier plan, **avant de prendre l'écran** — le seul moment où ssh peut poser une question sur le terminal — puis passe `BatchMode=yes` à tout ce qui suit, pour qu'un maître perdu échoue bruyamment au lieu d'afficher une demande de mot de passe par-dessus l'éditeur.
30+
31+`ControlPersist=60` garde le maître en vie une minute après son dernier utilisateur. L'éditeur tient le canal SFTP ouvert jusqu'à sa fermeture, donc cette minute ne compte qu'après un plantage : la connexion s'en va alors d'elle-même.
32+
33+Le coût, c'est Windows : son portage d'OpenSSH n'a pas de ControlMaster, et `Connect` y renvoie `ErrUnsupported`.
34+
35+## Pourquoi SFTP pour les fichiers, et pas des commandes shell
36+
37+Une fois la connexion partagée établie, la façon la moins chère en apparence de lire un fichier est `ssh … cat fichier`, et de lister un répertoire `ssh … ls`. Elle a été écartée pour deux raisons.
38+
39+La sortie de `ls` et de `stat` n'est pas portable — GNU et BSD divergent sur les options et les formats — et un surveillant de fichiers qui l'analyse casse au premier serveur macOS. Et un processus par opération, c'est un processus par fichier ouvert et par seconde rien que pour le surveillant.
40+
41+SFTP est le protocole qu'OpenSSH fournit précisément pour cela, sur un canal de la même connexion (`ssh -s … sftp`) : binaire, portable, et une requête par opération. Le client est `github.com/pkg/sftp`, la seule vraie dépendance ajoutée par la fonctionnalité. Écrire à la main la douzaine de messages SFTP dont l'éditeur a besoin a été envisagé et écarté : du code de protocole à maintenir, sans rien gagner.
42+
43+L'enregistrement atomique survit au voyage : le nouveau contenu est écrit dans un fichier temporaire à côté de la cible, puis renommé par-dessus avec `posix-rename@openssh.com`, qui remplace la cible en une étape, comme `rename(2)` en local.
44+
45+## Pourquoi toute commande distante est `/bin/sh -c '…'`
46+
47+ssh confie la commande qu'on lui donne au shell *de connexion* de l'utilisateur, qui peut être fish, ou tout autre shell qui ne lit pas `cd dir && exec …` comme sh. Toute commande envoyée est donc `exec /bin/sh -c '<script>'`, citée une fois pour le shell de connexion : la ligne de commande d'un outil signifie là-bas ce qu'elle signifie ici — `/bin/sh -c`, tubes et `&&` compris — et un terminal lance le shell de l'utilisateur avec `exec "${SHELL:-/bin/sh}" -l` depuis sh.
48+
49+## Ce qui est désactivé, et pourquoi
50+
51+- **Le serveur de langage.** Les serveurs qu'un éditeur sait trouver sont sur cette machine ; les fichiers sur lesquels on les interrogerait n'y sont pas. Lancer pylsp à distance sur la même connexion est possible, mais il faudrait alors traduire les chemins de ses réponses, et que le serveur soit installé là-bas. C'est laissé pour plus tard plutôt que fait à moitié.
52+- **Les fenêtres d'agent.** Un agent est un programme de cette machine, qui lit et écrit les fichiers de cette machine. Un agent qui tournerait pendant que l'éditeur montre le projet distant éditerait un autre projet que celui à l'écran. Le menu Agent le dit au lieu de le proposer.
53+
54+## Alternatives écartées
55+
56+- **Monter avec sshfs.** Il faut macFUSE sous macOS, une extension du noyau que la personne doit installer et approuver, et les terminaux et outils restent locaux.
57+- **Copier l'éditeur sur la machine et l'y lancer.** Le binaire est construit pour la machine de la personne — `darwin/arm64` — et le serveur est le plus souvent en `linux/amd64` ; et l'éditeur perdrait le presse-papiers local et les thèmes de la personne.
58+- **Une interface passée à travers chaque paquet** au lieu de `fsys.Use`. Voir « Une seule porte pour les fichiers » ci-dessus.
59+
60+## Comment cela se relie au reste
61+
62+`fsys` se trouve sous tout ce qui touche un fichier du projet, sans dépendance à lui. `remote` ne l'importe même pas — son `FS` a les bonnes méthodes, c'est tout ce que demande une interface Go. Il se trouve à côté de `configrepo`, puisque tous deux pilotent une commande installée, et `app` ne le connaît qu'à travers l'interface `app.Remote` à trois méthodes, si bien que ses tests utilisent une doublure qui lance `/bin/sh` en local. Chaque éditeur ajoute le flag dans son `main.go` ; la référence est [remote](../reference/remote.md), et la recette [ajouter l'édition à distance](../how-to/add-remote-editing.md).
added docs/fr/how-to/add-remote-editing.md +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+# Comment ajouter l'édition à distance à un éditeur
2+
3+Ce guide donne à un éditeur bâti sur turbo-core un flag `-ssh user@machine[:répertoire]`, pour qu'il édite les fichiers d'une autre machine et y lance ses terminaux et ses outils. Il suppose que votre `main.go` a la forme de celui de [construire un éditeur](../tutorials/build-an-editor.md) : flags, `loadProjectSettings`, `newScreen`, `app.New`, `StartLanguageServer`, `Run`.
4+
5+## Étapes
6+
7+1. Importez les deux paquets :
8+
9+ ```go
10+ "rickub.com/turbo-editors/turbo-core/fsys"
11+ "rickub.com/turbo-editors/turbo-core/remote"
12+ ```
13+
14+2. Ajoutez le flag. Le paquet `flag` de Go accepte indifféremment `-ssh` et `--ssh` :
15+
16+ ```go
17+ flag.StringVar(&opts.ssh, "ssh", "", "edit the files of another machine over ssh: user@machine[:directory]")
18+ ```
19+
20+3. Ajoutez la fonction qui se connecte et fait passer toute lecture de fichier sur la machine :
21+
22+ ```go
23+ func connectRemote(target string) (*remote.Session, error) {
24+ if target == "" {
25+ return nil, nil
26+ }
27+ session, err := remote.Open(context.Background(), target)
28+ if err != nil {
29+ return nil, err
30+ }
31+ fsys.Use(session.FS())
32+ return session, nil
33+ }
34+ ```
35+
36+4. Appelez-la en premier dans `edit`, **avant** `newScreen` — ssh peut avoir besoin du terminal pour demander un mot de passe — et fermez-la en sortant :
37+
38+ ```go
39+ session, err := connectRemote(opts.ssh)
40+ if err != nil {
41+ return err
42+ }
43+ if session != nil {
44+ defer session.Close()
45+ }
46+ ```
47+
48+5. Lisez le répertoire du projet via `fsys`, pour que ce soit celui de la machine distante :
49+
50+ ```go
51+ project, err := fsys.Getwd() // au lieu de os.Getwd()
52+ ```
53+
54+6. Prévenez l'éditeur, et ne démarrez pas de serveur de langage local pour des fichiers distants :
55+
56+ ```go
57+ editor := app.New(screen, themeName, p)
58+ if session != nil {
59+ editor.UseRemote(session)
60+ }
61+ // …
62+ if !opts.noLSP && session == nil {
63+ editor.StartLanguageServer(ctx, app.ProjectRoot(p, opts.files))
64+ }
65+ ```
66+
67+7. Compilez, puis essayez contre la machine de démo de `turbo-editors/demo-remote-machine` (son README explique comment la démarrer) :
68+
69+ ```bash
70+ go build -o bin/turbo-x . && ./bin/turbo-x -ssh turbo-demo:workspace/hello-python
71+ ```
72+
73+## Variantes
74+
75+- **Tester le flag sans machine.** Découpez `parseFlags` en une fonction qui reçoit un `*flag.FlagSet` et les arguments, et donnez-lui `flag.NewFlagSet(nom, flag.ContinueOnError)` dans un test. Une cible invalide comme `-oProxyCommand=x` est refusée par `connectRemote` avant que quoi que ce soit ne s'exécute : on peut donc la tester sans ssh.
76+- **Tester l'accès aux fichiers sans machine.** `fsys.Use(fsystest.New("/remote/project"))` dans un test fait passer toutes les lectures et écritures en mémoire ; voir [fsys](../reference/fsys.md).
77+- **Votre éditeur compile contre la bibliothèque publiée.** `fsys` et `remote` sont arrivés après la v1.0.4 ; exigez la version qui les contient, ou utilisez un workspace — [tester sans publier](test-without-publishing.md).
78+
79+## Voir aussi
80+
81+- Toutes les fonctions utilisées ici : [remote](../reference/remote.md), [fsys](../reference/fsys.md), [l'API app](../reference/app.md)
82+- Ce qui change dans l'éditeur, et pourquoi : [l'édition à distance](../explanation/remote-editing.md)
new file mode 100644
@@ -0,0 +1,82 @@
1+# Comment ajouter l'édition à distance à un éditeur
2+
3+Ce guide donne à un éditeur bâti sur turbo-core un flag `-ssh user@machine[:répertoire]`, pour qu'il édite les fichiers d'une autre machine et y lance ses terminaux et ses outils. Il suppose que votre `main.go` a la forme de celui de [construire un éditeur](../tutorials/build-an-editor.md) : flags, `loadProjectSettings`, `newScreen`, `app.New`, `StartLanguageServer`, `Run`.
4+
5+## Étapes
6+
7+1. Importez les deux paquets :
8+
9+ ```go
10+ "rickub.com/turbo-editors/turbo-core/fsys"
11+ "rickub.com/turbo-editors/turbo-core/remote"
12+ ```
13+
14+2. Ajoutez le flag. Le paquet `flag` de Go accepte indifféremment `-ssh` et `--ssh` :
15+
16+ ```go
17+ flag.StringVar(&opts.ssh, "ssh", "", "edit the files of another machine over ssh: user@machine[:directory]")
18+ ```
19+
20+3. Ajoutez la fonction qui se connecte et fait passer toute lecture de fichier sur la machine :
21+
22+ ```go
23+ func connectRemote(target string) (*remote.Session, error) {
24+ if target == "" {
25+ return nil, nil
26+ }
27+ session, err := remote.Open(context.Background(), target)
28+ if err != nil {
29+ return nil, err
30+ }
31+ fsys.Use(session.FS())
32+ return session, nil
33+ }
34+ ```
35+
36+4. Appelez-la en premier dans `edit`, **avant** `newScreen` — ssh peut avoir besoin du terminal pour demander un mot de passe — et fermez-la en sortant :
37+
38+ ```go
39+ session, err := connectRemote(opts.ssh)
40+ if err != nil {
41+ return err
42+ }
43+ if session != nil {
44+ defer session.Close()
45+ }
46+ ```
47+
48+5. Lisez le répertoire du projet via `fsys`, pour que ce soit celui de la machine distante :
49+
50+ ```go
51+ project, err := fsys.Getwd() // au lieu de os.Getwd()
52+ ```
53+
54+6. Prévenez l'éditeur, et ne démarrez pas de serveur de langage local pour des fichiers distants :
55+
56+ ```go
57+ editor := app.New(screen, themeName, p)
58+ if session != nil {
59+ editor.UseRemote(session)
60+ }
61+ // …
62+ if !opts.noLSP && session == nil {
63+ editor.StartLanguageServer(ctx, app.ProjectRoot(p, opts.files))
64+ }
65+ ```
66+
67+7. Compilez, puis essayez contre la machine de démo de `turbo-editors/demo-remote-machine` (son README explique comment la démarrer) :
68+
69+ ```bash
70+ go build -o bin/turbo-x . && ./bin/turbo-x -ssh turbo-demo:workspace/hello-python
71+ ```
72+
73+## Variantes
74+
75+- **Tester le flag sans machine.** Découpez `parseFlags` en une fonction qui reçoit un `*flag.FlagSet` et les arguments, et donnez-lui `flag.NewFlagSet(nom, flag.ContinueOnError)` dans un test. Une cible invalide comme `-oProxyCommand=x` est refusée par `connectRemote` avant que quoi que ce soit ne s'exécute : on peut donc la tester sans ssh.
76+- **Tester l'accès aux fichiers sans machine.** `fsys.Use(fsystest.New("/remote/project"))` dans un test fait passer toutes les lectures et écritures en mémoire ; voir [fsys](../reference/fsys.md).
77+- **Votre éditeur compile contre la bibliothèque publiée.** `fsys` et `remote` sont arrivés après la v1.0.4 ; exigez la version qui les contient, ou utilisez un workspace — [tester sans publier](test-without-publishing.md).
78+
79+## Voir aussi
80+
81+- Toutes les fonctions utilisées ici : [remote](../reference/remote.md), [fsys](../reference/fsys.md), [l'API app](../reference/app.md)
82+- Ce qui change dans l'éditeur, et pourquoi : [l'édition à distance](../explanation/remote-editing.md)
modified docs/fr/how-to/run-the-tests.md +11 -0
@@ -57,6 +57,17 @@ make help
5757
5858 Utilisez `make race`, pas `make test`. Une course de données dans un constructeur qui démarre une goroutine s'est cachée ici pendant toute une fonctionnalité, parce qu'un shell met plus longtemps à produire une sortie qu'une affectation à s'exécuter.
5959
60+### Vous avez modifié `remote`, et voulez un vrai sshd
61+
62+`go test ./remote/` n'a pas besoin du réseau : il fait tourner un vrai serveur SFTP à travers des tubes, et le binaire de test lui-même joue le rôle de ssh. Pour lancer le seul test qui parle à un vrai sshd, démarrez la machine de démo de `turbo-editors/demo-remote-machine` et donnez sa clé au test :
63+
64+```bash
65+(cd ../demo-remote-machine && ./make-key.sh && docker compose up -d --build)
66+TURBO_CORE_SSH_KEY=$PWD/../demo-remote-machine/keys/id_demo go test ./remote/ -run Real -v
67+```
68+
69+`TURBO_CORE_SSH_PORT` (par défaut `2222`) et `TURBO_CORE_SSH_TARGET` (par défaut `bob@127.0.0.1:workspace/hello-python`) le dirigent ailleurs. Sans `TURBO_CORE_SSH_KEY`, le test se saute lui-même.
70+
6071 ### Un test est passé dès que vous l'avez écrit
6172
6273 Cassez le code qu'il couvre et regardez-le échouer. Ce projet a plus d'un cas consigné de test qui passait parce qu'il ne testait rien — un rappel que personne n'avait branché, une étape que le test effectuait lui-même. Un nouveau test qui n'a jamais échoué n'a pas été testé.
@@ -57,6 +57,17 @@ make help
57 57
58 Utilisez `make race`, pas `make test`. Une course de données dans un constructeur qui démarre une goroutine s'est cachée ici pendant toute une fonctionnalité, parce qu'un shell met plus longtemps à produire une sortie qu'une affectation à s'exécuter.58 Utilisez `make race`, pas `make test`. Une course de données dans un constructeur qui démarre une goroutine s'est cachée ici pendant toute une fonctionnalité, parce qu'un shell met plus longtemps à produire une sortie qu'une affectation à s'exécuter.
59 59
60+### Vous avez modifié `remote`, et voulez un vrai sshd
61+
62+`go test ./remote/` n'a pas besoin du réseau : il fait tourner un vrai serveur SFTP à travers des tubes, et le binaire de test lui-même joue le rôle de ssh. Pour lancer le seul test qui parle à un vrai sshd, démarrez la machine de démo de `turbo-editors/demo-remote-machine` et donnez sa clé au test :
63+
64+```bash
65+(cd ../demo-remote-machine && ./make-key.sh && docker compose up -d --build)
66+TURBO_CORE_SSH_KEY=$PWD/../demo-remote-machine/keys/id_demo go test ./remote/ -run Real -v
67+```
68+
69+`TURBO_CORE_SSH_PORT` (par défaut `2222`) et `TURBO_CORE_SSH_TARGET` (par défaut `bob@127.0.0.1:workspace/hello-python`) le dirigent ailleurs. Sans `TURBO_CORE_SSH_KEY`, le test se saute lui-même.
70+
60 ### Un test est passé dès que vous l'avez écrit71 ### Un test est passé dès que vous l'avez écrit
61 72
62 Cassez le code qu'il couvre et regardez-le échouer. Ce projet a plus d'un cas consigné de test qui passait parce qu'il ne testait rien — un rappel que personne n'avait branché, une étape que le test effectuait lui-même. Un nouveau test qui n'a jamais échoué n'a pas été testé.73 Cassez le code qu'il couvre et regardez-le échouer. Ce projet a plus d'un cas consigné de test qui passait parce qu'il ne testait rien — un rappel que personne n'avait branché, une étape que le test effectuait lui-même. Un nouveau test qui n'a jamais échoué n'a pas été testé.
modified docs/fr/reference/app.md +18 -0
@@ -99,6 +99,23 @@ Sauvegarder maintient exact l'ensemble des documents que le serveur croit ouvert
9999 | `Message(text string)` | Une note d'une ligne sur la barre d'état. |
100100 | `ShowMessage(title, message string)` | Une boîte modale. |
101101
102+## Une autre machine
103+
104+| Méthode | Description |
105+| --- | --- |
106+| `UseRemote(r Remote)` | Indique à l'éditeur que son projet est sur une autre machine. À appeler après `app.New`, `fsys` ayant déjà été basculé sur cette machine. Dès lors, les fenêtres terminal et les outils passent par `r`, la barre d'état commence par `⇄ <r.Name()>`, et les fenêtres d'agent sont refusées avec un message. |
107+| `Remote() Remote` | La machine éditée, ou nil quand les fichiers sont locaux. |
108+
109+`Remote` est une interface ; `*remote.Session` l'implémente.
110+
111+| Méthode de `Remote` | Sert à |
112+| --- | --- |
113+| `Name() string` | La barre d'état, et le titre d'une fenêtre terminal tant que le shell ne s'est pas nommé. |
114+| `ShellCommand(dir string) []string` | L'argv de `New terminal` (F8) : un shell de connexion dans `dir`, le répertoire du fichier au premier plan ou du projet. Lancé depuis le répertoire de travail local de l'éditeur. |
115+| `RunCommand(dir, command string, withTerminal bool) []string` | L'argv d'un outil : `withTerminal` vaut true pour `output = "terminal"`, false pour `popup` et `editor`, qui passent par `tools.StartProgram`. `dir` est le répertoire du projet. |
116+
117+Ce que `UseRemote` ne fait pas : démarrer ou arrêter un serveur de langage (c'est la commande qui décide ; voir [ajouter l'édition à distance](../how-to/add-remote-editing.md)), ni changer l'endroit d'où sont lus les thèmes et les extraits de l'utilisateur.
118+
102119 ## Paramètres d'un outil
103120
104121 Une commande peut demander des valeurs avant de se lancer, en écrivant `{{libellé}}` à l'endroit où la valeur va. `app.RunTool` ouvre une boîte pour elles ; rien ici ne dessine quoi que ce soit.
@@ -131,3 +148,4 @@ Une commande peut demander des valeurs avant de se lancer, en écrivant `{{libel
131148
132149 - [Construire un éditeur](../tutorials/build-an-editor.md)
133150 - [Référence profile](profile.md)
151+- [remote](remote.md), [fsys](fsys.md)
@@ -99,6 +99,23 @@ Sauvegarder maintient exact l'ensemble des documents que le serveur croit ouvert
99 | `Message(text string)` | Une note d'une ligne sur la barre d'état. |99 | `Message(text string)` | Une note d'une ligne sur la barre d'état. |
100 | `ShowMessage(title, message string)` | Une boîte modale. |100 | `ShowMessage(title, message string)` | Une boîte modale. |
101 101
102+## Une autre machine
103+
104+| Méthode | Description |
105+| --- | --- |
106+| `UseRemote(r Remote)` | Indique à l'éditeur que son projet est sur une autre machine. À appeler après `app.New`, `fsys` ayant déjà été basculé sur cette machine. Dès lors, les fenêtres terminal et les outils passent par `r`, la barre d'état commence par `⇄ <r.Name()>`, et les fenêtres d'agent sont refusées avec un message. |
107+| `Remote() Remote` | La machine éditée, ou nil quand les fichiers sont locaux. |
108+
109+`Remote` est une interface ; `*remote.Session` l'implémente.
110+
111+| Méthode de `Remote` | Sert à |
112+| --- | --- |
113+| `Name() string` | La barre d'état, et le titre d'une fenêtre terminal tant que le shell ne s'est pas nommé. |
114+| `ShellCommand(dir string) []string` | L'argv de `New terminal` (F8) : un shell de connexion dans `dir`, le répertoire du fichier au premier plan ou du projet. Lancé depuis le répertoire de travail local de l'éditeur. |
115+| `RunCommand(dir, command string, withTerminal bool) []string` | L'argv d'un outil : `withTerminal` vaut true pour `output = "terminal"`, false pour `popup` et `editor`, qui passent par `tools.StartProgram`. `dir` est le répertoire du projet. |
116+
117+Ce que `UseRemote` ne fait pas : démarrer ou arrêter un serveur de langage (c'est la commande qui décide ; voir [ajouter l'édition à distance](../how-to/add-remote-editing.md)), ni changer l'endroit d'où sont lus les thèmes et les extraits de l'utilisateur.
118+
102 ## Paramètres d'un outil119 ## Paramètres d'un outil
103 120
104 Une commande peut demander des valeurs avant de se lancer, en écrivant `{{libellé}}` à l'endroit où la valeur va. `app.RunTool` ouvre une boîte pour elles ; rien ici ne dessine quoi que ce soit.121 Une commande peut demander des valeurs avant de se lancer, en écrivant `{{libellé}}` à l'endroit où la valeur va. `app.RunTool` ouvre une boîte pour elles ; rien ici ne dessine quoi que ce soit.
@@ -131,3 +148,4 @@ Une commande peut demander des valeurs avant de se lancer, en écrivant `{{libel
131 148
132 - [Construire un éditeur](../tutorials/build-an-editor.md)149 - [Construire un éditeur](../tutorials/build-an-editor.md)
133 - [Référence profile](profile.md)150 - [Référence profile](profile.md)
151+- [remote](remote.md), [fsys](fsys.md)
added docs/fr/reference/fsys.md +84 -0
new file mode 100644
@@ -0,0 +1,84 @@
1+# Référence : fsys
2+
3+> Description neutre du paquet `fsys`, la porte unique par laquelle l'éditeur atteint les fichiers du projet qu'il édite, et de `fsys/fsystest`, sa doublure en mémoire pour les tests.
4+
5+Chemin d'import : `rickub.com/turbo-editors/turbo-core/fsys`. Ne dépend que de la bibliothèque standard.
6+
7+## Ce qui passe par lui
8+
9+| Passe par `fsys` | Reste sur le disque local (paquet `os`) |
10+| --- | --- |
11+| Les fichiers source ouverts, rechargés et enregistrés (`buffer`) | Les thèmes de l'utilisateur (`theme`) |
12+| L'arborescence du projet (`filetree`) | Le fichier d'extraits personnel de l'utilisateur (`snippets.UserPath`) |
13+| Les dialogues Ouvrir et Enregistrer sous (`app`) | L'exécutable du serveur de langage (`lsp`) |
14+| `.turbo-<slug>/settings.toml`, `snippets.toml`, `tools.toml` (`settings`, `snippets`, `tools`, `projectfile`) | Le fichier des agents, et ce que les agents lisent et écrivent (`acp`) |
15+| Le répertoire de travail que l'éditeur considère comme le projet (`app`) | Le clone et la copie de `-load-config` (`configrepo`) |
16+| Le `stat` que le surveillant de fichiers fait chaque seconde (`app`) | |
17+
18+## Interface `FS`
19+
20+```go
21+type FS interface {
22+ ReadFile(name string) ([]byte, error)
23+ WriteFileAtomic(name string, data []byte, perm fs.FileMode) error
24+ ReadDir(name string) ([]fs.DirEntry, error)
25+ Stat(name string) (fs.FileInfo, error)
26+ MkdirAll(name string, perm fs.FileMode) error
27+ Getwd() (string, error)
28+}
29+```
30+
31+| Méthode | Contrat |
32+| --- | --- |
33+| `ReadFile` | Tout le contenu d'un fichier. |
34+| `WriteFileAtomic` | Remplace un fichier par `data` avec le mode `perm`, de sorte qu'un lecteur voie l'ancien contenu ou le nouveau, jamais la moitié. Le répertoire doit exister. |
35+| `ReadDir` | Les entrées d'un répertoire, triées par nom. |
36+| `Stat` | Décrit un fichier, en suivant les liens symboliques. |
37+| `MkdirAll` | Crée un répertoire et tous les parents manquants. |
38+| `Getwd` | Le répertoire absolu d'où partent les chemins relatifs. |
39+
40+Toute erreur signifiant « ce fichier n'existe pas » vérifie `errors.Is(err, fs.ErrNotExist)`. Les chemins relatifs sont relatifs à `Getwd`.
41+
42+## Fonctions
43+
44+| Fonction | Description |
45+| --- | --- |
46+| `Use(f FS) (previous FS)` | Fait de `f` le système de fichiers par lequel passent tous les paquets ; renvoie celui qu'il remplace. Sûr depuis n'importe quelle goroutine ; prévu pour être appelé une fois, au démarrage, avant l'ouverture de tout fichier. |
47+| `Current() FS` | Le système de fichiers en service. |
48+| `IsLocal() bool` | Si le système de fichiers en service est `Local`. |
49+| `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd` | Les méthodes de `FS`, sur `Current()`. |
50+| `Abs(path string) (string, error)` | `path` rendu absolu par rapport à `Getwd()`, et nettoyé. L'équivalent de `filepath.Abs` qui tient compte d'une machine distante. |
51+
52+## Type `Local`
53+
54+`Local{}` est la valeur par défaut : le disque de la machine où tourne l'éditeur, via le paquet `os`. `WriteFileAtomic` écrit `.<nom>.<aléa>` à côté de la cible, lui donne `perm`, et le renomme par-dessus la cible ; le fichier temporaire est supprimé à chaque échec.
55+
56+## Paquet `fsystest`
57+
58+Chemin d'import : `rickub.com/turbo-editors/turbo-core/fsys/fsystest`. Pour les tests uniquement.
59+
60+| Fonction / méthode | Description |
61+| --- | --- |
62+| `New(working string) *Memory` | Un `FS` en mémoire, vide, dont le répertoire de travail existe, ainsi que tous les répertoires au-dessus. Sûr en accès concurrent. |
63+| `(*Memory) AddFile(name, content string)` | Crée ou remplace un fichier avec le mode `0644`, en créant ses répertoires. |
64+| `(*Memory) File(name string) (string, bool)` | Le contenu d'un fichier, et s'il existe. |
65+| `(*Memory) Mode(name string) fs.FileMode` | Les permissions d'un fichier, `0` s'il est absent. |
66+
67+Chaque écriture avance la date de modification du fichier d'une nanoseconde, pour qu'un surveillant qui compare les estampilles voie chaque changement.
68+
69+## Exemple
70+
71+```go
72+memory := fsystest.New("/remote/project")
73+memory.AddFile("/remote/project/hello.py", "print('hello')\n")
74+
75+previous := fsys.Use(memory)
76+defer fsys.Use(previous)
77+
78+data, _ := fsys.ReadFile("hello.py") // "print('hello')\n"
79+```
80+
81+## Voir aussi
82+
83+- L'implémentation SFTP de `FS` : [remote](remote.md)
84+- Pourquoi les fichiers ont une seule porte : [l'édition à distance](../explanation/remote-editing.md)
new file mode 100644
@@ -0,0 +1,84 @@
1+# Référence : fsys
2+
3+> Description neutre du paquet `fsys`, la porte unique par laquelle l'éditeur atteint les fichiers du projet qu'il édite, et de `fsys/fsystest`, sa doublure en mémoire pour les tests.
4+
5+Chemin d'import : `rickub.com/turbo-editors/turbo-core/fsys`. Ne dépend que de la bibliothèque standard.
6+
7+## Ce qui passe par lui
8+
9+| Passe par `fsys` | Reste sur le disque local (paquet `os`) |
10+| --- | --- |
11+| Les fichiers source ouverts, rechargés et enregistrés (`buffer`) | Les thèmes de l'utilisateur (`theme`) |
12+| L'arborescence du projet (`filetree`) | Le fichier d'extraits personnel de l'utilisateur (`snippets.UserPath`) |
13+| Les dialogues Ouvrir et Enregistrer sous (`app`) | L'exécutable du serveur de langage (`lsp`) |
14+| `.turbo-<slug>/settings.toml`, `snippets.toml`, `tools.toml` (`settings`, `snippets`, `tools`, `projectfile`) | Le fichier des agents, et ce que les agents lisent et écrivent (`acp`) |
15+| Le répertoire de travail que l'éditeur considère comme le projet (`app`) | Le clone et la copie de `-load-config` (`configrepo`) |
16+| Le `stat` que le surveillant de fichiers fait chaque seconde (`app`) | |
17+
18+## Interface `FS`
19+
20+```go
21+type FS interface {
22+ ReadFile(name string) ([]byte, error)
23+ WriteFileAtomic(name string, data []byte, perm fs.FileMode) error
24+ ReadDir(name string) ([]fs.DirEntry, error)
25+ Stat(name string) (fs.FileInfo, error)
26+ MkdirAll(name string, perm fs.FileMode) error
27+ Getwd() (string, error)
28+}
29+```
30+
31+| Méthode | Contrat |
32+| --- | --- |
33+| `ReadFile` | Tout le contenu d'un fichier. |
34+| `WriteFileAtomic` | Remplace un fichier par `data` avec le mode `perm`, de sorte qu'un lecteur voie l'ancien contenu ou le nouveau, jamais la moitié. Le répertoire doit exister. |
35+| `ReadDir` | Les entrées d'un répertoire, triées par nom. |
36+| `Stat` | Décrit un fichier, en suivant les liens symboliques. |
37+| `MkdirAll` | Crée un répertoire et tous les parents manquants. |
38+| `Getwd` | Le répertoire absolu d'où partent les chemins relatifs. |
39+
40+Toute erreur signifiant « ce fichier n'existe pas » vérifie `errors.Is(err, fs.ErrNotExist)`. Les chemins relatifs sont relatifs à `Getwd`.
41+
42+## Fonctions
43+
44+| Fonction | Description |
45+| --- | --- |
46+| `Use(f FS) (previous FS)` | Fait de `f` le système de fichiers par lequel passent tous les paquets ; renvoie celui qu'il remplace. Sûr depuis n'importe quelle goroutine ; prévu pour être appelé une fois, au démarrage, avant l'ouverture de tout fichier. |
47+| `Current() FS` | Le système de fichiers en service. |
48+| `IsLocal() bool` | Si le système de fichiers en service est `Local`. |
49+| `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd` | Les méthodes de `FS`, sur `Current()`. |
50+| `Abs(path string) (string, error)` | `path` rendu absolu par rapport à `Getwd()`, et nettoyé. L'équivalent de `filepath.Abs` qui tient compte d'une machine distante. |
51+
52+## Type `Local`
53+
54+`Local{}` est la valeur par défaut : le disque de la machine où tourne l'éditeur, via le paquet `os`. `WriteFileAtomic` écrit `.<nom>.<aléa>` à côté de la cible, lui donne `perm`, et le renomme par-dessus la cible ; le fichier temporaire est supprimé à chaque échec.
55+
56+## Paquet `fsystest`
57+
58+Chemin d'import : `rickub.com/turbo-editors/turbo-core/fsys/fsystest`. Pour les tests uniquement.
59+
60+| Fonction / méthode | Description |
61+| --- | --- |
62+| `New(working string) *Memory` | Un `FS` en mémoire, vide, dont le répertoire de travail existe, ainsi que tous les répertoires au-dessus. Sûr en accès concurrent. |
63+| `(*Memory) AddFile(name, content string)` | Crée ou remplace un fichier avec le mode `0644`, en créant ses répertoires. |
64+| `(*Memory) File(name string) (string, bool)` | Le contenu d'un fichier, et s'il existe. |
65+| `(*Memory) Mode(name string) fs.FileMode` | Les permissions d'un fichier, `0` s'il est absent. |
66+
67+Chaque écriture avance la date de modification du fichier d'une nanoseconde, pour qu'un surveillant qui compare les estampilles voie chaque changement.
68+
69+## Exemple
70+
71+```go
72+memory := fsystest.New("/remote/project")
73+memory.AddFile("/remote/project/hello.py", "print('hello')\n")
74+
75+previous := fsys.Use(memory)
76+defer fsys.Use(previous)
77+
78+data, _ := fsys.ReadFile("hello.py") // "print('hello')\n"
79+```
80+
81+## Voir aussi
82+
83+- L'implémentation SFTP de `FS` : [remote](remote.md)
84+- Pourquoi les fichiers ont une seule porte : [l'édition à distance](../explanation/remote-editing.md)
modified docs/fr/reference/packages.md +23 -15
@@ -2,16 +2,19 @@
22
33 > Description neutre et exhaustive de ce que contient turbo-core.
44
5-Dix-neuf paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.
5+Vingt-deux paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.
66
77 ```
8-app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}
8+app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version, fsys}
9+ remote → github.com/pkg/sftp ← lance la commande ssh ; implémente fsys.FS sans importer fsys
910 editor → {buffer, syntax, ui, theme}
1011 terminal → {ui, theme, tcell, golang.org/x/sys}
11- filetree → {ui, theme, tcell}
12- snippets → {toml, projectfile, profile}
13- settings → {toml, projectfile, profile}
14- tools → {toml, projectfile, profile}
12+ filetree → {ui, theme, tcell, fsys}
13+ snippets → {toml, projectfile, profile, fsys}
14+ settings → {toml, projectfile, profile, fsys}
15+ tools → {toml, projectfile, profile, fsys}
16+ buffer, projectfile → fsys
17+ fsys → bibliothèque standard seule
1518 configrepo → {projectfile, profile} ← lance la commande git
1619 syntax → theme
1720 ui → theme
@@ -22,35 +25,39 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
2225 | Paquet | Ce qu'il contient | Dépend de |
2326 | --- | --- | --- |
2427 | `profile` | L'identité de l'éditeur : nom, slug, serveur de langage, modèles de départ, et les chemins qui en dérivent | **bibliothèque standard seule** |
25-| `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **bibliothèque standard seule** |
26-| `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **bibliothèque standard seule** |
28+| `fsys` | La porte unique vers les fichiers du projet : l'interface `FS`, le disque local, et `Use` pour faire passer tous les paquets sur une autre machine | **bibliothèque standard seule** |
29+| `fsys/fsystest` | Un `fsys.FS` en mémoire, pour les tests | `fsys` |
30+| `remote` | Une autre machine via la commande `ssh` : une connexion ControlMaster, les fichiers en SFTP, les lignes de commande des shells et outils distants (`-ssh`) | `github.com/pkg/sftp`, la commande `ssh` ; son `FS` satisfait `fsys.FS` sans importer `fsys` |
31+| `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | `fsys` |
32+| `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | `fsys` |
2733 | `configrepo` | Un répertoire de configuration partagé, rapporté depuis l'URL d'une forge (rickub, GitHub, GitLab, Codeberg) par un clone git superficiel, pour le `-load-config` d'un éditeur | `projectfile`, `profile`, la commande `git` |
2834 | `version` | De quelle construction de lui-même un binaire est issu : marquage par l'éditeur de liens, puis informations de build Go | **bibliothèque standard seule** |
2935 | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** |
3036 | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` |
3137 | `theme` | Du TOML vers des styles tcell ; onze thèmes embarqués plus ceux de l'utilisateur ; deux sortes d'héritage | `tcell`, `toml` |
3238 | `syntax` | La coloration : neuf langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` |
33-| `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile` |
34-| `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile` |
35-| `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve | `toml`, `projectfile`, `profile` |
39+| `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile`, `fsys` |
40+| `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile`, `fsys` |
41+| `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve ; `Start` passe par le shell local, `StartProgram` lance un argv tel qu'une commande distante | `toml`, `projectfile`, `profile`, `fsys` |
3642 | `ui` | Les composants Turbo Vision : peintre, bureau, fenêtre, menu, dialogue, contrôles, barre d'état | `theme`, `tcell` |
3743 | `editor` | Le composant d'édition : cadrage, défilement, touches, souris, insertion d'extraits | `buffer`, `syntax`, `theme`, `ui` |
3844 | `terminal` | Un shell dans une fenêtre : pseudo-terminal, émulateur VT/ANSI, grille d'écran, le composant | `theme`, `ui`, `tcell`, `golang.org/x/sys` |
39-| `filetree` | Les fichiers d'un projet en arborescence dépliable, et la fenêtre qui l'affiche | `theme`, `ui`, `tcell` |
45+| `filetree` | Les fichiers d'un projet en arborescence dépliable, et la fenêtre qui l'affiche | `theme`, `ui`, `tcell`, `fsys` |
4046 | `acp` | Un agent de code dans une fenêtre : l'Agent Client Protocol, la conversation, le widget | `jsonrpc`, `profile`, `projectfile`, `syntax`, `theme`, `ui`, `tcell`, `toml` |
41-| `app` | L'assemblage : menus, dialogues, routage des événements, popup de complétion, cycle de vie du serveur de langage | tout ce qui précède |
47+| `app` | L'assemblage : menus, dialogues, routage des événements, popup de complétion, cycle de vie du serveur de langage, shells et outils distants (`UseRemote`) | tout ce qui précède sauf `remote`, qu'il ne connaît qu'à travers l'interface `app.Remote` |
4248
4349 ## Dépendances externes
4450
45-Trois, et seulement trois :
51+Quatre directes :
4652
4753 | Module | Pourquoi |
4854 | --- | --- |
4955 | `github.com/gdamore/tcell/v2` | le terminal |
5056 | `github.com/BurntSushi/toml` | tous les fichiers de configuration que l'éditeur lit |
5157 | `golang.org/x/sys` | les ioctls du pseudo-terminal sous Linux et macOS ; les appels de pseudo-console, de processus et de job object sous Windows |
58+| `github.com/pkg/sftp` | le client SFTP avec lequel `remote` lit et écrit les fichiers d'une machine, sur le canal qu'ouvre ssh ; il amène `github.com/kr/fs` et `golang.org/x/crypto` en dépendances indirectes |
5259
53-L'analyseur lexical, le client JSON-RPC, le cadrage LSP et l'émulateur VT/ANSI sont écrits à la main.
60+L'analyseur lexical, le client JSON-RPC, le cadrage LSP et l'émulateur VT/ANSI sont écrits à la main. SSH lui-même n'est pas dans le binaire : `remote` lance la commande `ssh` installée.
5461
5562 ## Le diagramme
5663
@@ -59,3 +66,4 @@ L'analyseur lexical, le client JSON-RPC, le cadrage LSP et l'émulateur VT/ANSI
5966 ## Voir aussi
6067
6168 - Pourquoi le graphe a cette forme : [architecture](../explanation/architecture.md)
69+- Les deux paquets de l'édition à distance : [fsys](fsys.md), [remote](remote.md)
@@ -2,16 +2,19 @@
2 2
3 > Description neutre et exhaustive de ce que contient turbo-core.3 > Description neutre et exhaustive de ce que contient turbo-core.
4 4
5-Dix-neuf paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.5+Vingt-deux paquets. Les dépendances vont strictement vers le bas ; il n'y a ni cycle ni indirection par interface pour en empêcher un.
6 6
7 ```7 ```
8-app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version}8+app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version, fsys}
9+ remote → github.com/pkg/sftp ← lance la commande ssh ; implémente fsys.FS sans importer fsys
9 editor → {buffer, syntax, ui, theme}10 editor → {buffer, syntax, ui, theme}
10 terminal → {ui, theme, tcell, golang.org/x/sys}11 terminal → {ui, theme, tcell, golang.org/x/sys}
11- filetree → {ui, theme, tcell}12+ filetree → {ui, theme, tcell, fsys}
12- snippets → {toml, projectfile, profile}13+ snippets → {toml, projectfile, profile, fsys}
13- settings → {toml, projectfile, profile}14+ settings → {toml, projectfile, profile, fsys}
14- tools → {toml, projectfile, profile}15+ tools → {toml, projectfile, profile, fsys}
16+ buffer, projectfile → fsys
17+ fsys → bibliothèque standard seule
15 configrepo → {projectfile, profile} ← lance la commande git18 configrepo → {projectfile, profile} ← lance la commande git
16 syntax → theme19 syntax → theme
17 ui → theme20 ui → theme
@@ -22,35 +25,39 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools,
22 | Paquet | Ce qu'il contient | Dépend de |25 | Paquet | Ce qu'il contient | Dépend de |
23 | --- | --- | --- |26 | --- | --- | --- |
24 | `profile` | L'identité de l'éditeur : nom, slug, serveur de langage, modèles de départ, et les chemins qui en dérivent | **bibliothèque standard seule** |27 | `profile` | L'identité de l'éditeur : nom, slug, serveur de langage, modèles de départ, et les chemins qui en dérivent | **bibliothèque standard seule** |
25-| `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **bibliothèque standard seule** |28+| `fsys` | La porte unique vers les fichiers du projet : l'interface `FS`, le disque local, et `Use` pour faire passer tous les paquets sur une autre machine | **bibliothèque standard seule** |
26-| `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **bibliothèque standard seule** |29+| `fsys/fsystest` | Un `fsys.FS` en mémoire, pour les tests | `fsys` |
30+| `remote` | Une autre machine via la commande `ssh` : une connexion ControlMaster, les fichiers en SFTP, les lignes de commande des shells et outils distants (`-ssh`) | `github.com/pkg/sftp`, la commande `ssh` ; son `FS` satisfait `fsys.FS` sans importer `fsys` |
31+| `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | `fsys` |
32+| `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | `fsys` |
27 | `configrepo` | Un répertoire de configuration partagé, rapporté depuis l'URL d'une forge (rickub, GitHub, GitLab, Codeberg) par un clone git superficiel, pour le `-load-config` d'un éditeur | `projectfile`, `profile`, la commande `git` |33 | `configrepo` | Un répertoire de configuration partagé, rapporté depuis l'URL d'une forge (rickub, GitHub, GitLab, Codeberg) par un clone git superficiel, pour le `-load-config` d'un éditeur | `projectfile`, `profile`, la commande `git` |
28 | `version` | De quelle construction de lui-même un binaire est issu : marquage par l'éditeur de liens, puis informations de build Go | **bibliothèque standard seule** |34 | `version` | De quelle construction de lui-même un binaire est issu : marquage par l'éditeur de liens, puis informations de build Go | **bibliothèque standard seule** |
29 | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** |35 | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** |
30 | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` |36 | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` |
31 | `theme` | Du TOML vers des styles tcell ; onze thèmes embarqués plus ceux de l'utilisateur ; deux sortes d'héritage | `tcell`, `toml` |37 | `theme` | Du TOML vers des styles tcell ; onze thèmes embarqués plus ceux de l'utilisateur ; deux sortes d'héritage | `tcell`, `toml` |
32 | `syntax` | La coloration : neuf langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` |38 | `syntax` | La coloration : neuf langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` |
33-| `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile` |39+| `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile`, `fsys` |
34-| `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile` |40+| `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile`, `fsys` |
35-| `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve | `toml`, `projectfile`, `profile` |41+| `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve ; `Start` passe par le shell local, `StartProgram` lance un argv tel qu'une commande distante | `toml`, `projectfile`, `profile`, `fsys` |
36 | `ui` | Les composants Turbo Vision : peintre, bureau, fenêtre, menu, dialogue, contrôles, barre d'état | `theme`, `tcell` |42 | `ui` | Les composants Turbo Vision : peintre, bureau, fenêtre, menu, dialogue, contrôles, barre d'état | `theme`, `tcell` |
37 | `editor` | Le composant d'édition : cadrage, défilement, touches, souris, insertion d'extraits | `buffer`, `syntax`, `theme`, `ui` |43 | `editor` | Le composant d'édition : cadrage, défilement, touches, souris, insertion d'extraits | `buffer`, `syntax`, `theme`, `ui` |
38 | `terminal` | Un shell dans une fenêtre : pseudo-terminal, émulateur VT/ANSI, grille d'écran, le composant | `theme`, `ui`, `tcell`, `golang.org/x/sys` |44 | `terminal` | Un shell dans une fenêtre : pseudo-terminal, émulateur VT/ANSI, grille d'écran, le composant | `theme`, `ui`, `tcell`, `golang.org/x/sys` |
39-| `filetree` | Les fichiers d'un projet en arborescence dépliable, et la fenêtre qui l'affiche | `theme`, `ui`, `tcell` |45+| `filetree` | Les fichiers d'un projet en arborescence dépliable, et la fenêtre qui l'affiche | `theme`, `ui`, `tcell`, `fsys` |
40 | `acp` | Un agent de code dans une fenêtre : l'Agent Client Protocol, la conversation, le widget | `jsonrpc`, `profile`, `projectfile`, `syntax`, `theme`, `ui`, `tcell`, `toml` |46 | `acp` | Un agent de code dans une fenêtre : l'Agent Client Protocol, la conversation, le widget | `jsonrpc`, `profile`, `projectfile`, `syntax`, `theme`, `ui`, `tcell`, `toml` |
41-| `app` | L'assemblage : menus, dialogues, routage des événements, popup de complétion, cycle de vie du serveur de langage | tout ce qui précède |47+| `app` | L'assemblage : menus, dialogues, routage des événements, popup de complétion, cycle de vie du serveur de langage, shells et outils distants (`UseRemote`) | tout ce qui précède sauf `remote`, qu'il ne connaît qu'à travers l'interface `app.Remote` |
42 48
43 ## Dépendances externes49 ## Dépendances externes
44 50
45-Trois, et seulement trois :51+Quatre directes :
46 52
47 | Module | Pourquoi |53 | Module | Pourquoi |
48 | --- | --- |54 | --- | --- |
49 | `github.com/gdamore/tcell/v2` | le terminal |55 | `github.com/gdamore/tcell/v2` | le terminal |
50 | `github.com/BurntSushi/toml` | tous les fichiers de configuration que l'éditeur lit |56 | `github.com/BurntSushi/toml` | tous les fichiers de configuration que l'éditeur lit |
51 | `golang.org/x/sys` | les ioctls du pseudo-terminal sous Linux et macOS ; les appels de pseudo-console, de processus et de job object sous Windows |57 | `golang.org/x/sys` | les ioctls du pseudo-terminal sous Linux et macOS ; les appels de pseudo-console, de processus et de job object sous Windows |
58+| `github.com/pkg/sftp` | le client SFTP avec lequel `remote` lit et écrit les fichiers d'une machine, sur le canal qu'ouvre ssh ; il amène `github.com/kr/fs` et `golang.org/x/crypto` en dépendances indirectes |
52 59
53-L'analyseur lexical, le client JSON-RPC, le cadrage LSP et l'émulateur VT/ANSI sont écrits à la main.60+L'analyseur lexical, le client JSON-RPC, le cadrage LSP et l'émulateur VT/ANSI sont écrits à la main. SSH lui-même n'est pas dans le binaire : `remote` lance la commande `ssh` installée.
54 61
55 ## Le diagramme62 ## Le diagramme
56 63
@@ -59,3 +66,4 @@ L'analyseur lexical, le client JSON-RPC, le cadrage LSP et l'émulateur VT/ANSI
59 ## Voir aussi66 ## Voir aussi
60 67
61 - Pourquoi le graphe a cette forme : [architecture](../explanation/architecture.md)68 - Pourquoi le graphe a cette forme : [architecture](../explanation/architecture.md)
69+- Les deux paquets de l'édition à distance : [fsys](fsys.md), [remote](remote.md)
added docs/fr/reference/remote.md +115 -0
new file mode 100644
@@ -0,0 +1,115 @@
1+# Référence : remote
2+
3+> Description neutre du paquet `remote`, qui atteint une autre machine via la commande `ssh` installée : une connexion partagée, les fichiers en SFTP, et les lignes de commande des shells et outils distants.
4+
5+Chemin d'import : `rickub.com/turbo-editors/turbo-core/remote`. Dépend de `github.com/pkg/sftp`, et de la commande `ssh` à l'exécution. Son `FS` satisfait `fsys.FS` sans importer `fsys`.
6+
7+## Type `Target`
8+
9+Ce que nomme `-ssh` : `[user@]host[:path]`, comme `scp` écrit un emplacement distant.
10+
11+| Champ | Description |
12+| --- | --- |
13+| `User` | Sous quel utilisateur se connecter. Vide, c'est ssh qui décide (`User` dans `~/.ssh/config`, sinon le nom de l'utilisateur local). |
14+| `Host` | Un nom, une adresse, ou un alias `Host` de `~/.ssh/config`. Une adresse IPv6 s'écrit entre crochets : `bob@[2001:db8::1]:/srv`. |
15+| `Path` | Le répertoire à ouvrir. Vide ou `~` désigne le home distant ; `~/x` et un chemin relatif sont relatifs à lui. |
16+
17+| Fonction / méthode | Description |
18+| --- | --- |
19+| `ParseTarget(argument string) (Target, error)` | Lit `[user@]host[:path]`. L'utilisateur est tout ce qui précède le **dernier** `@`. |
20+| `(Target) Destination() string` | `user@host`, ou `host` quand aucun utilisateur n'est nommé : ce qu'on donne à ssh. |
21+| `(Target) String() string` | La cible réécrite telle qu'on la tape. |
22+
23+`ParseTarget` renvoie une erreur qui enveloppe `ErrBadTarget` pour : un hôte ou un utilisateur vide, un hôte ou un utilisateur commençant par `-` (ssh le lirait comme une option), une espace, une tabulation, un saut de ligne ou un `/` dans l'hôte ou l'utilisateur, et des `[…]` non fermés ou mal placés.
24+
25+La syntaxe n'a pas de port ; un port se met dans `~/.ssh/config` sous un alias `Host`.
26+
27+## Se connecter
28+
29+| Fonction | Description |
30+| --- | --- |
31+| `Open(ctx, argument string) (*Session, error)` | `ParseTarget`, puis affiche `Connecting to <cible>…` sur la sortie d'erreur, puis `Connect` avec les `Options` par défaut. C'est ce qu'appelle le flag `-ssh` d'un éditeur. |
32+| `Connect(ctx, target Target, options Options) (*Session, error)` | Se connecte, ouvre le canal des fichiers, résout le répertoire. Doit être appelé avant que l'éditeur ne prenne le terminal. |
33+
34+`Options` :
35+
36+| Champ | Défaut | Description |
37+| --- | --- | --- |
38+| `Program` | `"ssh"` trouvé dans le `PATH` | La commande ssh à lancer. |
39+
40+Ce que lance `Connect`, dans l'ordre :
41+
42+| Étape | Commande | Remarques |
43+| --- | --- | --- |
44+| Connexion | `ssh -o ControlMaster=yes -o ControlPath=<socket> -o ControlPersist=60 -N -f -- <destination>` | Au premier plan, sur le terminal : c'est là que ssh demande un mot de passe, une passphrase, ou s'il faut faire confiance à une nouvelle clé d'hôte. Rend la main une fois connecté ; la connexion reste derrière la socket. |
45+| Fichiers | `ssh -S <socket> -o ControlMaster=no -o BatchMode=yes -s -- <destination> sftp` | Un seul canal SFTP, ouvert jusqu'à `Close`. |
46+| Répertoire | `realpath` puis `stat` SFTP de `Path` | Doit être un répertoire existant. |
47+
48+La socket est `<tmp>/turbo-ssh-XXXX/control`, où `<tmp>` est `os.TempDir()`, ou `/tmp` s'il dépasse 60 octets (le chemin d'une socket Unix est limité à une centaine).
49+
50+## Type `Session`
51+
52+| Méthode | Description |
53+| --- | --- |
54+| `FS() *FS` | Les fichiers distants, enracinés dans le répertoire édité. Ce que la commande passe à `fsys.Use`. |
55+| `Target() Target` | Ce à quoi on s'est connecté. |
56+| `Name() string` | `Destination()` : comment la machine est affichée. |
57+| `ShellCommand(dir string) []string` | L'argv d'un shell de connexion interactif dans `dir` : `ssh -S … -t -- <destination> "exec /bin/sh -c 'cd <dir> && exec \"${SHELL:-/bin/sh}\" -l'"`. |
58+| `RunCommand(dir, command string, withTerminal bool) []string` | L'argv qui lance une ligne de commande avec `/bin/sh` dans `dir` : `ssh -S … -t|-T -- <destination> "exec /bin/sh -c 'cd <dir> && <command>'"`. `-t` si `withTerminal`, sinon `-T`. |
59+| `Close() error` | Ferme le canal des fichiers, lance `ssh -S … -O exit -- <destination>`, supprime le répertoire de la socket. Les terminaux et outils qui passent par la connexion s'arrêtent avec elle. Peut être appelé deux fois. |
60+
61+`Session` implémente `app.Remote`.
62+
63+Toute commande après la connexion passe `ControlMaster=no` et `BatchMode=yes` : elle n'ouvre jamais de connexion à elle et ne pose jamais de question, parce qu'à ce moment-là l'éditeur occupe le terminal.
64+
65+## Type `FS`
66+
67+Les fichiers distants en SFTP ; implémente `fsys.FS`. Les chemins sont séparés par des `/` (`path`, pas `path/filepath`), relatifs au répertoire édité.
68+
69+| Méthode | Requêtes SFTP |
70+| --- | --- |
71+| `ReadFile` | `open`, `read`…, `close` |
72+| `WriteFileAtomic` | `open` de `.<nom>.<aléa>` avec `O_EXCL`, `write`…, `close`, `setstat` (mode), `posix-rename@openssh.com` par-dessus la cible ; `remove` du temporaire en cas d'échec |
73+| `ReadDir` | `opendir`, `readdir`… ; trié par nom |
74+| `Stat` | `stat` |
75+| `MkdirAll` | `stat` / `mkdir` à chaque niveau ; l'umask distant décide du mode |
76+| `Getwd` | aucune : le répertoire résolu par `Connect` |
77+
78+Le statut « fichier inexistant » d'un serveur est enveloppé pour que `errors.Is(err, fs.ErrNotExist)` soit vrai.
79+
80+## Erreurs
81+
82+| Erreur | Cause |
83+| --- | --- |
84+| `ErrBadTarget` | L'argument de `-ssh` n'est pas `[user@]host[:path]`, ou ssh le lirait comme une option. |
85+| `ErrNoSSH` | Pas de commande ssh dans le `PATH`. |
86+| `ErrUnsupported` | Windows : son portage d'OpenSSH n'a pas de ControlMaster. |
87+| `ssh <destination>: exit status 255` | La connexion a échoué ; ssh a déjà dit pourquoi sur le terminal. |
88+| `opening the file channel to <destination>: … (<ce qu'a dit ssh>)` | Le serveur a refusé le sous-système `sftp`. |
89+| `finding <path> on the remote machine: …` / `opening … : …` | Le répertoire n'existe pas. |
90+| `<path> on the remote machine is not a directory: invalid argument` | Le chemin désigne un fichier. |
91+
92+## Voisin : `tools.StartProgram`
93+
94+```go
95+func StartProgram(command string, argv []string, onLine func()) (*tools.Run, error)
96+```
97+
98+Lance `argv` sans shell local intermédiaire ; `command` est le nom de l'exécution (`Run.Command()`, le titre du dialogue). Sorties fusionnées, arrêt avec `Stop`, comme `tools.Start`. Un `argv` vide est une erreur. C'est ainsi qu'un outil capturé s'exécute via ssh : `tools.StartProgram(command, session.RunCommand(dir, command, false), wake)`.
99+
100+## Exemple
101+
102+```go
103+session, err := remote.Open(ctx, "bob@build-box:/srv/app")
104+if err != nil {
105+ return err
106+}
107+defer session.Close()
108+fsys.Use(session.FS())
109+```
110+
111+## Voir aussi
112+
113+- L'interface qu'il implémente : [fsys](fsys.md)
114+- Le brancher dans un éditeur : [ajouter l'édition à distance](../how-to/add-remote-editing.md)
115+- Pourquoi il pilote la commande ssh : [l'édition à distance](../explanation/remote-editing.md)
new file mode 100644
@@ -0,0 +1,115 @@
1+# Référence : remote
2+
3+> Description neutre du paquet `remote`, qui atteint une autre machine via la commande `ssh` installée : une connexion partagée, les fichiers en SFTP, et les lignes de commande des shells et outils distants.
4+
5+Chemin d'import : `rickub.com/turbo-editors/turbo-core/remote`. Dépend de `github.com/pkg/sftp`, et de la commande `ssh` à l'exécution. Son `FS` satisfait `fsys.FS` sans importer `fsys`.
6+
7+## Type `Target`
8+
9+Ce que nomme `-ssh` : `[user@]host[:path]`, comme `scp` écrit un emplacement distant.
10+
11+| Champ | Description |
12+| --- | --- |
13+| `User` | Sous quel utilisateur se connecter. Vide, c'est ssh qui décide (`User` dans `~/.ssh/config`, sinon le nom de l'utilisateur local). |
14+| `Host` | Un nom, une adresse, ou un alias `Host` de `~/.ssh/config`. Une adresse IPv6 s'écrit entre crochets : `bob@[2001:db8::1]:/srv`. |
15+| `Path` | Le répertoire à ouvrir. Vide ou `~` désigne le home distant ; `~/x` et un chemin relatif sont relatifs à lui. |
16+
17+| Fonction / méthode | Description |
18+| --- | --- |
19+| `ParseTarget(argument string) (Target, error)` | Lit `[user@]host[:path]`. L'utilisateur est tout ce qui précède le **dernier** `@`. |
20+| `(Target) Destination() string` | `user@host`, ou `host` quand aucun utilisateur n'est nommé : ce qu'on donne à ssh. |
21+| `(Target) String() string` | La cible réécrite telle qu'on la tape. |
22+
23+`ParseTarget` renvoie une erreur qui enveloppe `ErrBadTarget` pour : un hôte ou un utilisateur vide, un hôte ou un utilisateur commençant par `-` (ssh le lirait comme une option), une espace, une tabulation, un saut de ligne ou un `/` dans l'hôte ou l'utilisateur, et des `[…]` non fermés ou mal placés.
24+
25+La syntaxe n'a pas de port ; un port se met dans `~/.ssh/config` sous un alias `Host`.
26+
27+## Se connecter
28+
29+| Fonction | Description |
30+| --- | --- |
31+| `Open(ctx, argument string) (*Session, error)` | `ParseTarget`, puis affiche `Connecting to <cible>…` sur la sortie d'erreur, puis `Connect` avec les `Options` par défaut. C'est ce qu'appelle le flag `-ssh` d'un éditeur. |
32+| `Connect(ctx, target Target, options Options) (*Session, error)` | Se connecte, ouvre le canal des fichiers, résout le répertoire. Doit être appelé avant que l'éditeur ne prenne le terminal. |
33+
34+`Options` :
35+
36+| Champ | Défaut | Description |
37+| --- | --- | --- |
38+| `Program` | `"ssh"` trouvé dans le `PATH` | La commande ssh à lancer. |
39+
40+Ce que lance `Connect`, dans l'ordre :
41+
42+| Étape | Commande | Remarques |
43+| --- | --- | --- |
44+| Connexion | `ssh -o ControlMaster=yes -o ControlPath=<socket> -o ControlPersist=60 -N -f -- <destination>` | Au premier plan, sur le terminal : c'est là que ssh demande un mot de passe, une passphrase, ou s'il faut faire confiance à une nouvelle clé d'hôte. Rend la main une fois connecté ; la connexion reste derrière la socket. |
45+| Fichiers | `ssh -S <socket> -o ControlMaster=no -o BatchMode=yes -s -- <destination> sftp` | Un seul canal SFTP, ouvert jusqu'à `Close`. |
46+| Répertoire | `realpath` puis `stat` SFTP de `Path` | Doit être un répertoire existant. |
47+
48+La socket est `<tmp>/turbo-ssh-XXXX/control`, où `<tmp>` est `os.TempDir()`, ou `/tmp` s'il dépasse 60 octets (le chemin d'une socket Unix est limité à une centaine).
49+
50+## Type `Session`
51+
52+| Méthode | Description |
53+| --- | --- |
54+| `FS() *FS` | Les fichiers distants, enracinés dans le répertoire édité. Ce que la commande passe à `fsys.Use`. |
55+| `Target() Target` | Ce à quoi on s'est connecté. |
56+| `Name() string` | `Destination()` : comment la machine est affichée. |
57+| `ShellCommand(dir string) []string` | L'argv d'un shell de connexion interactif dans `dir` : `ssh -S … -t -- <destination> "exec /bin/sh -c 'cd <dir> && exec \"${SHELL:-/bin/sh}\" -l'"`. |
58+| `RunCommand(dir, command string, withTerminal bool) []string` | L'argv qui lance une ligne de commande avec `/bin/sh` dans `dir` : `ssh -S … -t|-T -- <destination> "exec /bin/sh -c 'cd <dir> && <command>'"`. `-t` si `withTerminal`, sinon `-T`. |
59+| `Close() error` | Ferme le canal des fichiers, lance `ssh -S … -O exit -- <destination>`, supprime le répertoire de la socket. Les terminaux et outils qui passent par la connexion s'arrêtent avec elle. Peut être appelé deux fois. |
60+
61+`Session` implémente `app.Remote`.
62+
63+Toute commande après la connexion passe `ControlMaster=no` et `BatchMode=yes` : elle n'ouvre jamais de connexion à elle et ne pose jamais de question, parce qu'à ce moment-là l'éditeur occupe le terminal.
64+
65+## Type `FS`
66+
67+Les fichiers distants en SFTP ; implémente `fsys.FS`. Les chemins sont séparés par des `/` (`path`, pas `path/filepath`), relatifs au répertoire édité.
68+
69+| Méthode | Requêtes SFTP |
70+| --- | --- |
71+| `ReadFile` | `open`, `read`…, `close` |
72+| `WriteFileAtomic` | `open` de `.<nom>.<aléa>` avec `O_EXCL`, `write`…, `close`, `setstat` (mode), `posix-rename@openssh.com` par-dessus la cible ; `remove` du temporaire en cas d'échec |
73+| `ReadDir` | `opendir`, `readdir`… ; trié par nom |
74+| `Stat` | `stat` |
75+| `MkdirAll` | `stat` / `mkdir` à chaque niveau ; l'umask distant décide du mode |
76+| `Getwd` | aucune : le répertoire résolu par `Connect` |
77+
78+Le statut « fichier inexistant » d'un serveur est enveloppé pour que `errors.Is(err, fs.ErrNotExist)` soit vrai.
79+
80+## Erreurs
81+
82+| Erreur | Cause |
83+| --- | --- |
84+| `ErrBadTarget` | L'argument de `-ssh` n'est pas `[user@]host[:path]`, ou ssh le lirait comme une option. |
85+| `ErrNoSSH` | Pas de commande ssh dans le `PATH`. |
86+| `ErrUnsupported` | Windows : son portage d'OpenSSH n'a pas de ControlMaster. |
87+| `ssh <destination>: exit status 255` | La connexion a échoué ; ssh a déjà dit pourquoi sur le terminal. |
88+| `opening the file channel to <destination>: … (<ce qu'a dit ssh>)` | Le serveur a refusé le sous-système `sftp`. |
89+| `finding <path> on the remote machine: …` / `opening … : …` | Le répertoire n'existe pas. |
90+| `<path> on the remote machine is not a directory: invalid argument` | Le chemin désigne un fichier. |
91+
92+## Voisin : `tools.StartProgram`
93+
94+```go
95+func StartProgram(command string, argv []string, onLine func()) (*tools.Run, error)
96+```
97+
98+Lance `argv` sans shell local intermédiaire ; `command` est le nom de l'exécution (`Run.Command()`, le titre du dialogue). Sorties fusionnées, arrêt avec `Stop`, comme `tools.Start`. Un `argv` vide est une erreur. C'est ainsi qu'un outil capturé s'exécute via ssh : `tools.StartProgram(command, session.RunCommand(dir, command, false), wake)`.
99+
100+## Exemple
101+
102+```go
103+session, err := remote.Open(ctx, "bob@build-box:/srv/app")
104+if err != nil {
105+ return err
106+}
107+defer session.Close()
108+fsys.Use(session.FS())
109+```
110+
111+## Voir aussi
112+
113+- L'interface qu'il implémente : [fsys](fsys.md)
114+- Le brancher dans un éditeur : [ajouter l'édition à distance](../how-to/add-remote-editing.md)
115+- Pourquoi il pilote la commande ssh : [l'édition à distance](../explanation/remote-editing.md)
modified filetree/README.md +1 -1
@@ -2,7 +2,7 @@
22
33 A project's files as an expandable tree, and the widget that shows one.
44
5-Depends on `ui` for its place on screen and `theme` for its colours, and on nothing above it. `app` opens and owns the window; this package knows nothing about menus, buffers or language servers.
5+Depends on `ui` for its place on screen, `theme` for its colours and `fsys` for the directories it lists — the remote machine's under `-ssh` — and on nothing above it. `app` opens and owns the window; this package knows nothing about menus, buffers or language servers.
66
77 ## Two halves
88
@@ -2,7 +2,7 @@
2 2
3 A project's files as an expandable tree, and the widget that shows one.3 A project's files as an expandable tree, and the widget that shows one.
4 4
5-Depends on `ui` for its place on screen and `theme` for its colours, and on nothing above it. `app` opens and owns the window; this package knows nothing about menus, buffers or language servers.5+Depends on `ui` for its place on screen, `theme` for its colours and `fsys` for the directories it lists — the remote machine's under `-ssh` — and on nothing above it. `app` opens and owns the window; this package knows nothing about menus, buffers or language servers.
6 6
7 ## Two halves7 ## Two halves
8 8
added filetree/fsys_test.go +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+package filetree
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestTheTreeListsTheCurrentFileSystem(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+ memory.AddFile("/remote/project/main.py", "")
15+ memory.AddFile("/remote/project/pkg/util.py", "")
16+
17+ tree, err := New(".")
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if tree.Root().Path() != "/remote/project" {
22+ t.Errorf("root = %q, want the current file system's working directory", tree.Root().Path())
23+ }
24+ var names []string
25+ for _, row := range tree.Rows() {
26+ names = append(names, row.Node.Name())
27+ }
28+ if len(names) != 2 || names[0] != "pkg" || names[1] != "main.py" {
29+ t.Errorf("rows = %v, want [pkg main.py]", names)
30+ }
31+}
new file mode 100644
@@ -0,0 +1,31 @@
1+package filetree
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestTheTreeListsTheCurrentFileSystem(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+ memory.AddFile("/remote/project/main.py", "")
15+ memory.AddFile("/remote/project/pkg/util.py", "")
16+
17+ tree, err := New(".")
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if tree.Root().Path() != "/remote/project" {
22+ t.Errorf("root = %q, want the current file system's working directory", tree.Root().Path())
23+ }
24+ var names []string
25+ for _, row := range tree.Rows() {
26+ names = append(names, row.Node.Name())
27+ }
28+ if len(names) != 2 || names[0] != "pkg" || names[1] != "main.py" {
29+ t.Errorf("rows = %v, want [pkg main.py]", names)
30+ }
31+}
modified filetree/tree.go +5 -4
@@ -17,9 +17,10 @@ package filetree
1717
1818 import (
1919 "fmt"
20- "os"
2120 "path/filepath"
2221 "sort"
22+
23+ "rickub.com/turbo-editors/turbo-core/fsys"
2324 )
2425
2526 // hiddenName is the one directory the tree never shows.
@@ -109,12 +110,12 @@ type Tree struct {
109110 //
110111 // tree, err := filetree.New("/src/myproject")
111112 func New(root string) (*Tree, error) {
112- absolute, err := filepath.Abs(root)
113+ absolute, err := fsys.Abs(root)
113114 if err != nil {
114115 return nil, fmt.Errorf("resolving %s: %w", root, err)
115116 }
116117
117- info, err := os.Stat(absolute)
118+ info, err := fsys.Stat(absolute)
118119 if err != nil {
119120 return nil, fmt.Errorf("reading %s: %w", absolute, err)
120121 }
@@ -200,7 +201,7 @@ func (n *Node) refresh() {
200201 // unreadable directory in a project is not a reason to refuse to show the rest
201202 // of it, and Refresh will pick it up if its permissions change.
202203 func readChildren(directory string) []*Node {
203- entries, err := os.ReadDir(directory)
204+ entries, err := fsys.ReadDir(directory)
204205 if err != nil {
205206 return nil
206207 }
@@ -17,9 +17,10 @@ package filetree
17 17
18 import (18 import (
19 "fmt"19 "fmt"
20- "os"
21 "path/filepath"20 "path/filepath"
22 "sort"21 "sort"
22+
23+ "rickub.com/turbo-editors/turbo-core/fsys"
23 )24 )
24 25
25 // hiddenName is the one directory the tree never shows.26 // hiddenName is the one directory the tree never shows.
@@ -109,12 +110,12 @@ type Tree struct {
109 //110 //
110 // tree, err := filetree.New("/src/myproject")111 // tree, err := filetree.New("/src/myproject")
111 func New(root string) (*Tree, error) {112 func New(root string) (*Tree, error) {
112- absolute, err := filepath.Abs(root)113+ absolute, err := fsys.Abs(root)
113 if err != nil {114 if err != nil {
114 return nil, fmt.Errorf("resolving %s: %w", root, err)115 return nil, fmt.Errorf("resolving %s: %w", root, err)
115 }116 }
116 117
117- info, err := os.Stat(absolute)118+ info, err := fsys.Stat(absolute)
118 if err != nil {119 if err != nil {
119 return nil, fmt.Errorf("reading %s: %w", absolute, err)120 return nil, fmt.Errorf("reading %s: %w", absolute, err)
120 }121 }
@@ -200,7 +201,7 @@ func (n *Node) refresh() {
200 // unreadable directory in a project is not a reason to refuse to show the rest201 // unreadable directory in a project is not a reason to refuse to show the rest
201 // of it, and Refresh will pick it up if its permissions change.202 // of it, and Refresh will pick it up if its permissions change.
202 func readChildren(directory string) []*Node {203 func readChildren(directory string) []*Node {
203- entries, err := os.ReadDir(directory)204+ entries, err := fsys.ReadDir(directory)
204 if err != nil {205 if err != nil {
205 return nil206 return nil
206 }207 }
added fsys/README.md +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+# fsys
2+
3+The one door through which the editor reaches the files of the project it edits: the source files, the project tree, the Open and Save As dialogs, and the project's own `settings.toml`, `snippets.toml` and `tools.toml`.
4+
5+Imports the standard library and nothing else.
6+
7+```go
8+data, err := fsys.ReadFile("main.go")
9+err = fsys.WriteFileAtomic("main.go", data, 0o644)
10+```
11+
12+## Why it exists
13+
14+So that the files can live on another machine. An editor started with `-ssh bob@machine` calls `fsys.Use(session.FS())` once, before anything reads; from then on `buffer`, `filetree`, `settings`, `snippets`, `tools`, `projectfile` and `app` read and write over SFTP, and none of them knows. Without that call nothing changes: the default is `Local{}`, the disk, through package `os`.
15+
16+It is package-level state, set once at start-up — the shape `syntax.Register` has, for the same reason: threading a file-system value through every package and call site would change half the library's signatures for a choice made once per process.
17+
18+## What stays out
19+
20+What belongs to the person rather than the project stays on the local disk, through `os`: the user's themes, the user's own snippets file, the language server's executable, coding agents and their files, and `-load-config`'s clone.
21+
22+## Public API
23+
24+| Name | What it does |
25+| --- | --- |
26+| `FS` | `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd`. A missing file is always `errors.Is(err, fs.ErrNotExist)`. |
27+| `Local` | The disk, through `os`. `WriteFileAtomic` writes `.<name>.<random>` beside the target, sets the mode, renames. |
28+| `Use(f) (previous)` | Makes `f` current; returns the previous one so a test can put it back. |
29+| `Current()`, `IsLocal()` | The file system in use; whether it is `Local`. |
30+| `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd` | The same, on `Current()`. |
31+| `Abs(path)` | `filepath.Abs` against `Getwd()` — the remote working directory under `-ssh`. |
32+
33+`fsystest.New(working)` is an in-memory `FS` for tests, with `AddFile`, `File` and `Mode`. A test that finds a file only in memory has proved the code under test went through `fsys`, because the disk never had it.
34+
35+## Tests
36+
37+```sh
38+go test ./fsys/...
39+```
40+
41+The packages that go through `fsys` each have an `fsys_test.go` that swaps in `fsystest.Memory` and checks the file arrived there, not on disk.
new file mode 100644
@@ -0,0 +1,41 @@
1+# fsys
2+
3+The one door through which the editor reaches the files of the project it edits: the source files, the project tree, the Open and Save As dialogs, and the project's own `settings.toml`, `snippets.toml` and `tools.toml`.
4+
5+Imports the standard library and nothing else.
6+
7+```go
8+data, err := fsys.ReadFile("main.go")
9+err = fsys.WriteFileAtomic("main.go", data, 0o644)
10+```
11+
12+## Why it exists
13+
14+So that the files can live on another machine. An editor started with `-ssh bob@machine` calls `fsys.Use(session.FS())` once, before anything reads; from then on `buffer`, `filetree`, `settings`, `snippets`, `tools`, `projectfile` and `app` read and write over SFTP, and none of them knows. Without that call nothing changes: the default is `Local{}`, the disk, through package `os`.
15+
16+It is package-level state, set once at start-up — the shape `syntax.Register` has, for the same reason: threading a file-system value through every package and call site would change half the library's signatures for a choice made once per process.
17+
18+## What stays out
19+
20+What belongs to the person rather than the project stays on the local disk, through `os`: the user's themes, the user's own snippets file, the language server's executable, coding agents and their files, and `-load-config`'s clone.
21+
22+## Public API
23+
24+| Name | What it does |
25+| --- | --- |
26+| `FS` | `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd`. A missing file is always `errors.Is(err, fs.ErrNotExist)`. |
27+| `Local` | The disk, through `os`. `WriteFileAtomic` writes `.<name>.<random>` beside the target, sets the mode, renames. |
28+| `Use(f) (previous)` | Makes `f` current; returns the previous one so a test can put it back. |
29+| `Current()`, `IsLocal()` | The file system in use; whether it is `Local`. |
30+| `ReadFile`, `WriteFileAtomic`, `ReadDir`, `Stat`, `MkdirAll`, `Getwd` | The same, on `Current()`. |
31+| `Abs(path)` | `filepath.Abs` against `Getwd()` — the remote working directory under `-ssh`. |
32+
33+`fsystest.New(working)` is an in-memory `FS` for tests, with `AddFile`, `File` and `Mode`. A test that finds a file only in memory has proved the code under test went through `fsys`, because the disk never had it.
34+
35+## Tests
36+
37+```sh
38+go test ./fsys/...
39+```
40+
41+The packages that go through `fsys` each have an `fsys_test.go` that swaps in `fsystest.Memory` and checks the file arrived there, not on disk.
added fsys/fsys.go +139 -0
new file mode 100644
@@ -0,0 +1,139 @@
1+// Package fsys is the one door through which the editor reaches the files it
2+// edits: the source files, the project tree, the Open dialog, and the
3+// project's own settings, snippets and tools files.
4+//
5+// It exists so that those files can live somewhere else. An editor started
6+// with -ssh bob@machine calls Use once, before anything reads, with a file
7+// system that speaks SFTP to that machine; from then on every package that
8+// goes through here reads and writes over there, and none of them knows.
9+// Without that call, nothing changes: the default is the local disk, through
10+// package os.
11+//
12+// data, err := fsys.ReadFile("main.go")
13+// err = fsys.WriteFileAtomic("main.go", data, 0o644)
14+//
15+// What deliberately does not come through here is what belongs to the person
16+// rather than the project: the user's own themes, the language server's
17+// executable, a coding agent's configuration. Those stay on the machine the
18+// editor runs on.
19+//
20+// The current file system is package-level state, set once at start-up —
21+// the same shape syntax.Register has, for the same reason: threading a value
22+// through buffer, filetree, settings, snippets, tools and every call site in
23+// app would be a change to all of them, for a choice made once per process.
24+package fsys
25+
26+import (
27+ "io/fs"
28+ "path/filepath"
29+ "sync"
30+)
31+
32+// FS is what the editor needs from a file system. Paths are slash-separated
33+// on Unix, as package os expects; a relative path is relative to Getwd.
34+//
35+// Every error that means "there is no such file" must satisfy
36+// errors.Is(err, fs.ErrNotExist): opening a file that does not exist yet is
37+// how a new file is made, and callers tell that case apart this way.
38+type FS interface {
39+ // ReadFile returns the whole content of a file.
40+ ReadFile(name string) ([]byte, error)
41+ // WriteFileAtomic replaces a file with data, giving it perm, so that a
42+ // reader only ever sees the old content or the new one — never half.
43+ // The directory must exist.
44+ WriteFileAtomic(name string, data []byte, perm fs.FileMode) error
45+ // ReadDir lists a directory, sorted by name.
46+ ReadDir(name string) ([]fs.DirEntry, error)
47+ // Stat describes a file, following symbolic links.
48+ Stat(name string) (fs.FileInfo, error)
49+ // MkdirAll makes a directory and any parent it lacks.
50+ MkdirAll(name string, perm fs.FileMode) error
51+ // Getwd returns the absolute directory relative paths start from.
52+ Getwd() (string, error)
53+}
54+
55+var (
56+ mu sync.RWMutex
57+ current FS = Local{}
58+)
59+
60+// Use makes f the file system every package reads and writes through, and
61+// returns the one it replaces so a test can put it back.
62+//
63+// Call it at start-up, before the editor opens a file. It is safe to call from
64+// any goroutine, but swapping file systems under an editor that already has
65+// files open would leave it holding paths that mean something else.
66+//
67+// previous := fsys.Use(session.FS())
68+// defer fsys.Use(previous)
69+func Use(f FS) (previous FS) {
70+ mu.Lock()
71+ defer mu.Unlock()
72+ previous, current = current, f
73+ return previous
74+}
75+
76+// Current returns the file system in use.
77+func Current() FS {
78+ mu.RLock()
79+ defer mu.RUnlock()
80+ return current
81+}
82+
83+// IsLocal reports whether the file system in use is the local disk — which is
84+// what decides, for instance, whether a local language server can be pointed
85+// at the project.
86+func IsLocal() bool {
87+ _, local := Current().(Local)
88+ return local
89+}
90+
91+// ReadFile reads a file through the current file system.
92+//
93+// data, err := fsys.ReadFile(".turbo-go/settings.toml")
94+func ReadFile(name string) ([]byte, error) { return Current().ReadFile(name) }
95+
96+// WriteFileAtomic replaces a file through the current file system; see
97+// FS.WriteFileAtomic.
98+//
99+// err := fsys.WriteFileAtomic("main.go", []byte(text), 0o644)
100+func WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
101+ return Current().WriteFileAtomic(name, data, perm)
102+}
103+
104+// ReadDir lists a directory through the current file system.
105+//
106+// entries, err := fsys.ReadDir(".")
107+func ReadDir(name string) ([]fs.DirEntry, error) { return Current().ReadDir(name) }
108+
109+// Stat describes a file through the current file system.
110+//
111+// info, err := fsys.Stat("go.mod")
112+func Stat(name string) (fs.FileInfo, error) { return Current().Stat(name) }
113+
114+// MkdirAll makes a directory through the current file system.
115+//
116+// err := fsys.MkdirAll(".turbo-go", 0o755)
117+func MkdirAll(name string, perm fs.FileMode) error { return Current().MkdirAll(name, perm) }
118+
119+// Getwd returns the directory relative paths start from: the process's working
120+// directory locally, the directory given after the colon of -ssh remotely.
121+//
122+// project, err := fsys.Getwd()
123+func Getwd() (string, error) { return Current().Getwd() }
124+
125+// Abs makes a path absolute against Getwd, and cleans it. It is
126+// filepath.Abs for whichever machine the files are on — filepath.Abs itself
127+// would resolve a remote path against the local working directory.
128+//
129+// absolute, err := fsys.Abs("cmd/main.go")
130+func Abs(path string) (string, error) {
131+ if filepath.IsAbs(path) {
132+ return filepath.Clean(path), nil
133+ }
134+ working, err := Getwd()
135+ if err != nil {
136+ return "", err
137+ }
138+ return filepath.Join(working, path), nil
139+}
new file mode 100644
@@ -0,0 +1,139 @@
1+// Package fsys is the one door through which the editor reaches the files it
2+// edits: the source files, the project tree, the Open dialog, and the
3+// project's own settings, snippets and tools files.
4+//
5+// It exists so that those files can live somewhere else. An editor started
6+// with -ssh bob@machine calls Use once, before anything reads, with a file
7+// system that speaks SFTP to that machine; from then on every package that
8+// goes through here reads and writes over there, and none of them knows.
9+// Without that call, nothing changes: the default is the local disk, through
10+// package os.
11+//
12+// data, err := fsys.ReadFile("main.go")
13+// err = fsys.WriteFileAtomic("main.go", data, 0o644)
14+//
15+// What deliberately does not come through here is what belongs to the person
16+// rather than the project: the user's own themes, the language server's
17+// executable, a coding agent's configuration. Those stay on the machine the
18+// editor runs on.
19+//
20+// The current file system is package-level state, set once at start-up —
21+// the same shape syntax.Register has, for the same reason: threading a value
22+// through buffer, filetree, settings, snippets, tools and every call site in
23+// app would be a change to all of them, for a choice made once per process.
24+package fsys
25+
26+import (
27+ "io/fs"
28+ "path/filepath"
29+ "sync"
30+)
31+
32+// FS is what the editor needs from a file system. Paths are slash-separated
33+// on Unix, as package os expects; a relative path is relative to Getwd.
34+//
35+// Every error that means "there is no such file" must satisfy
36+// errors.Is(err, fs.ErrNotExist): opening a file that does not exist yet is
37+// how a new file is made, and callers tell that case apart this way.
38+type FS interface {
39+ // ReadFile returns the whole content of a file.
40+ ReadFile(name string) ([]byte, error)
41+ // WriteFileAtomic replaces a file with data, giving it perm, so that a
42+ // reader only ever sees the old content or the new one — never half.
43+ // The directory must exist.
44+ WriteFileAtomic(name string, data []byte, perm fs.FileMode) error
45+ // ReadDir lists a directory, sorted by name.
46+ ReadDir(name string) ([]fs.DirEntry, error)
47+ // Stat describes a file, following symbolic links.
48+ Stat(name string) (fs.FileInfo, error)
49+ // MkdirAll makes a directory and any parent it lacks.
50+ MkdirAll(name string, perm fs.FileMode) error
51+ // Getwd returns the absolute directory relative paths start from.
52+ Getwd() (string, error)
53+}
54+
55+var (
56+ mu sync.RWMutex
57+ current FS = Local{}
58+)
59+
60+// Use makes f the file system every package reads and writes through, and
61+// returns the one it replaces so a test can put it back.
62+//
63+// Call it at start-up, before the editor opens a file. It is safe to call from
64+// any goroutine, but swapping file systems under an editor that already has
65+// files open would leave it holding paths that mean something else.
66+//
67+// previous := fsys.Use(session.FS())
68+// defer fsys.Use(previous)
69+func Use(f FS) (previous FS) {
70+ mu.Lock()
71+ defer mu.Unlock()
72+ previous, current = current, f
73+ return previous
74+}
75+
76+// Current returns the file system in use.
77+func Current() FS {
78+ mu.RLock()
79+ defer mu.RUnlock()
80+ return current
81+}
82+
83+// IsLocal reports whether the file system in use is the local disk — which is
84+// what decides, for instance, whether a local language server can be pointed
85+// at the project.
86+func IsLocal() bool {
87+ _, local := Current().(Local)
88+ return local
89+}
90+
91+// ReadFile reads a file through the current file system.
92+//
93+// data, err := fsys.ReadFile(".turbo-go/settings.toml")
94+func ReadFile(name string) ([]byte, error) { return Current().ReadFile(name) }
95+
96+// WriteFileAtomic replaces a file through the current file system; see
97+// FS.WriteFileAtomic.
98+//
99+// err := fsys.WriteFileAtomic("main.go", []byte(text), 0o644)
100+func WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
101+ return Current().WriteFileAtomic(name, data, perm)
102+}
103+
104+// ReadDir lists a directory through the current file system.
105+//
106+// entries, err := fsys.ReadDir(".")
107+func ReadDir(name string) ([]fs.DirEntry, error) { return Current().ReadDir(name) }
108+
109+// Stat describes a file through the current file system.
110+//
111+// info, err := fsys.Stat("go.mod")
112+func Stat(name string) (fs.FileInfo, error) { return Current().Stat(name) }
113+
114+// MkdirAll makes a directory through the current file system.
115+//
116+// err := fsys.MkdirAll(".turbo-go", 0o755)
117+func MkdirAll(name string, perm fs.FileMode) error { return Current().MkdirAll(name, perm) }
118+
119+// Getwd returns the directory relative paths start from: the process's working
120+// directory locally, the directory given after the colon of -ssh remotely.
121+//
122+// project, err := fsys.Getwd()
123+func Getwd() (string, error) { return Current().Getwd() }
124+
125+// Abs makes a path absolute against Getwd, and cleans it. It is
126+// filepath.Abs for whichever machine the files are on — filepath.Abs itself
127+// would resolve a remote path against the local working directory.
128+//
129+// absolute, err := fsys.Abs("cmd/main.go")
130+func Abs(path string) (string, error) {
131+ if filepath.IsAbs(path) {
132+ return filepath.Clean(path), nil
133+ }
134+ working, err := Getwd()
135+ if err != nil {
136+ return "", err
137+ }
138+ return filepath.Join(working, path), nil
139+}
added fsys/fsys_test.go +150 -0
new file mode 100644
@@ -0,0 +1,150 @@
1+package fsys_test
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "rickub.com/turbo-editors/turbo-core/fsys"
12+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
13+)
14+
15+// useMemory swaps an in-memory file system in for the test's duration.
16+func useMemory(t *testing.T, working string) *fsystest.Memory {
17+ t.Helper()
18+ memory := fsystest.New(working)
19+ previous := fsys.Use(memory)
20+ t.Cleanup(func() { fsys.Use(previous) })
21+ return memory
22+}
23+
24+func TestTheDefaultFileSystemIsTheLocalDisk(t *testing.T) {
25+ if !fsys.IsLocal() {
26+ t.Fatalf("Current() = %T, want fsys.Local", fsys.Current())
27+ }
28+}
29+
30+func TestUseReplacesTheFileSystemAndReturnsThePreviousOne(t *testing.T) {
31+ memory := fsystest.New("/remote")
32+ previous := fsys.Use(memory)
33+ defer fsys.Use(previous)
34+
35+ if _, ok := previous.(fsys.Local); !ok {
36+ t.Errorf("previous = %T, want fsys.Local", previous)
37+ }
38+ if fsys.Current() != memory {
39+ t.Errorf("Current() did not return the file system just given to Use")
40+ }
41+ if fsys.IsLocal() {
42+ t.Errorf("IsLocal() = true with a memory file system in use")
43+ }
44+}
45+
46+func TestPackageFunctionsGoThroughTheCurrentFileSystem(t *testing.T) {
47+ memory := useMemory(t, "/remote/project")
48+ memory.AddFile("/remote/project/main.py", "print(1)\n")
49+
50+ data, err := fsys.ReadFile("main.py")
51+ if err != nil || string(data) != "print(1)\n" {
52+ t.Fatalf("ReadFile = %q, %v", data, err)
53+ }
54+ if err := fsys.MkdirAll("pkg/sub", 0o755); err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := fsys.WriteFileAtomic("pkg/sub/a.py", []byte("x = 1\n"), 0o600); err != nil {
58+ t.Fatal(err)
59+ }
60+ if got, _ := memory.File("/remote/project/pkg/sub/a.py"); got != "x = 1\n" {
61+ t.Errorf("written file = %q", got)
62+ }
63+ info, err := fsys.Stat("pkg")
64+ if err != nil || !info.IsDir() {
65+ t.Errorf("Stat(pkg) = %v, %v; want a directory", info, err)
66+ }
67+ entries, err := fsys.ReadDir(".")
68+ if err != nil || len(entries) != 2 || entries[0].Name() != "main.py" || entries[1].Name() != "pkg" {
69+ t.Errorf("ReadDir(.) = %v, %v", entries, err)
70+ }
71+ if working, _ := fsys.Getwd(); working != "/remote/project" {
72+ t.Errorf("Getwd = %q", working)
73+ }
74+}
75+
76+func TestAbsResolvesAgainstTheCurrentFileSystemsWorkingDirectory(t *testing.T) {
77+ useMemory(t, "/home/bob/app")
78+
79+ cases := map[string]string{
80+ "main.py": "/home/bob/app/main.py",
81+ "../other/x.py": "/home/bob/other/x.py",
82+ "/etc/hosts": "/etc/hosts",
83+ "/srv//a/../b/c": "/srv/b/c",
84+ }
85+ for in, want := range cases {
86+ if got, err := fsys.Abs(in); err != nil || got != want {
87+ t.Errorf("Abs(%q) = %q, %v; want %q", in, got, err, want)
88+ }
89+ }
90+}
91+
92+func TestLocalWriteFileAtomicReplacesTheContentWithTheGivenMode(t *testing.T) {
93+ directory := t.TempDir()
94+ target := filepath.Join(directory, "main.go")
95+ if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
96+ t.Fatal(err)
97+ }
98+
99+ if err := (fsys.Local{}).WriteFileAtomic(target, []byte("new"), 0o755); err != nil {
100+ t.Fatal(err)
101+ }
102+
103+ data, _ := os.ReadFile(target)
104+ if string(data) != "new" {
105+ t.Errorf("content = %q, want new", data)
106+ }
107+ info, _ := os.Stat(target)
108+ if info.Mode().Perm() != 0o755 {
109+ t.Errorf("mode = %v, want 0755", info.Mode().Perm())
110+ }
111+ entries, _ := os.ReadDir(directory)
112+ if len(entries) != 1 {
113+ t.Errorf("directory holds %d entries, want only the target: a temporary was left behind", len(entries))
114+ }
115+}
116+
117+func TestLocalWriteFileAtomicFailsInADirectoryThatDoesNotExist(t *testing.T) {
118+ target := filepath.Join(t.TempDir(), "missing", "main.go")
119+ err := (fsys.Local{}).WriteFileAtomic(target, []byte("x"), 0o644)
120+ if !errors.Is(err, fs.ErrNotExist) {
121+ t.Errorf("err = %v, want fs.ErrNotExist", err)
122+ }
123+}
124+
125+func TestAMissingFileIsReportedAsNotExistByBothImplementations(t *testing.T) {
126+ for name, system := range map[string]fsys.FS{
127+ "local": fsys.Local{},
128+ "memory": fsystest.New("/"),
129+ } {
130+ missing := filepath.Join(t.TempDir(), "nope")
131+ if _, err := system.ReadFile(missing); !errors.Is(err, fs.ErrNotExist) {
132+ t.Errorf("%s ReadFile: %v, want fs.ErrNotExist", name, err)
133+ }
134+ if _, err := system.Stat(missing); !errors.Is(err, fs.ErrNotExist) {
135+ t.Errorf("%s Stat: %v, want fs.ErrNotExist", name, err)
136+ }
137+ }
138+}
139+
140+func ExampleUse() {
141+ remote := fsystest.New("/home/bob/project")
142+ remote.AddFile("/home/bob/project/hello.py", "print('hello')\n")
143+
144+ previous := fsys.Use(remote)
145+ defer fsys.Use(previous)
146+
147+ data, _ := fsys.ReadFile("hello.py")
148+ fmt.Print(string(data))
149+ // Output: print('hello')
150+}
new file mode 100644
@@ -0,0 +1,150 @@
1+package fsys_test
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "rickub.com/turbo-editors/turbo-core/fsys"
12+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
13+)
14+
15+// useMemory swaps an in-memory file system in for the test's duration.
16+func useMemory(t *testing.T, working string) *fsystest.Memory {
17+ t.Helper()
18+ memory := fsystest.New(working)
19+ previous := fsys.Use(memory)
20+ t.Cleanup(func() { fsys.Use(previous) })
21+ return memory
22+}
23+
24+func TestTheDefaultFileSystemIsTheLocalDisk(t *testing.T) {
25+ if !fsys.IsLocal() {
26+ t.Fatalf("Current() = %T, want fsys.Local", fsys.Current())
27+ }
28+}
29+
30+func TestUseReplacesTheFileSystemAndReturnsThePreviousOne(t *testing.T) {
31+ memory := fsystest.New("/remote")
32+ previous := fsys.Use(memory)
33+ defer fsys.Use(previous)
34+
35+ if _, ok := previous.(fsys.Local); !ok {
36+ t.Errorf("previous = %T, want fsys.Local", previous)
37+ }
38+ if fsys.Current() != memory {
39+ t.Errorf("Current() did not return the file system just given to Use")
40+ }
41+ if fsys.IsLocal() {
42+ t.Errorf("IsLocal() = true with a memory file system in use")
43+ }
44+}
45+
46+func TestPackageFunctionsGoThroughTheCurrentFileSystem(t *testing.T) {
47+ memory := useMemory(t, "/remote/project")
48+ memory.AddFile("/remote/project/main.py", "print(1)\n")
49+
50+ data, err := fsys.ReadFile("main.py")
51+ if err != nil || string(data) != "print(1)\n" {
52+ t.Fatalf("ReadFile = %q, %v", data, err)
53+ }
54+ if err := fsys.MkdirAll("pkg/sub", 0o755); err != nil {
55+ t.Fatal(err)
56+ }
57+ if err := fsys.WriteFileAtomic("pkg/sub/a.py", []byte("x = 1\n"), 0o600); err != nil {
58+ t.Fatal(err)
59+ }
60+ if got, _ := memory.File("/remote/project/pkg/sub/a.py"); got != "x = 1\n" {
61+ t.Errorf("written file = %q", got)
62+ }
63+ info, err := fsys.Stat("pkg")
64+ if err != nil || !info.IsDir() {
65+ t.Errorf("Stat(pkg) = %v, %v; want a directory", info, err)
66+ }
67+ entries, err := fsys.ReadDir(".")
68+ if err != nil || len(entries) != 2 || entries[0].Name() != "main.py" || entries[1].Name() != "pkg" {
69+ t.Errorf("ReadDir(.) = %v, %v", entries, err)
70+ }
71+ if working, _ := fsys.Getwd(); working != "/remote/project" {
72+ t.Errorf("Getwd = %q", working)
73+ }
74+}
75+
76+func TestAbsResolvesAgainstTheCurrentFileSystemsWorkingDirectory(t *testing.T) {
77+ useMemory(t, "/home/bob/app")
78+
79+ cases := map[string]string{
80+ "main.py": "/home/bob/app/main.py",
81+ "../other/x.py": "/home/bob/other/x.py",
82+ "/etc/hosts": "/etc/hosts",
83+ "/srv//a/../b/c": "/srv/b/c",
84+ }
85+ for in, want := range cases {
86+ if got, err := fsys.Abs(in); err != nil || got != want {
87+ t.Errorf("Abs(%q) = %q, %v; want %q", in, got, err, want)
88+ }
89+ }
90+}
91+
92+func TestLocalWriteFileAtomicReplacesTheContentWithTheGivenMode(t *testing.T) {
93+ directory := t.TempDir()
94+ target := filepath.Join(directory, "main.go")
95+ if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
96+ t.Fatal(err)
97+ }
98+
99+ if err := (fsys.Local{}).WriteFileAtomic(target, []byte("new"), 0o755); err != nil {
100+ t.Fatal(err)
101+ }
102+
103+ data, _ := os.ReadFile(target)
104+ if string(data) != "new" {
105+ t.Errorf("content = %q, want new", data)
106+ }
107+ info, _ := os.Stat(target)
108+ if info.Mode().Perm() != 0o755 {
109+ t.Errorf("mode = %v, want 0755", info.Mode().Perm())
110+ }
111+ entries, _ := os.ReadDir(directory)
112+ if len(entries) != 1 {
113+ t.Errorf("directory holds %d entries, want only the target: a temporary was left behind", len(entries))
114+ }
115+}
116+
117+func TestLocalWriteFileAtomicFailsInADirectoryThatDoesNotExist(t *testing.T) {
118+ target := filepath.Join(t.TempDir(), "missing", "main.go")
119+ err := (fsys.Local{}).WriteFileAtomic(target, []byte("x"), 0o644)
120+ if !errors.Is(err, fs.ErrNotExist) {
121+ t.Errorf("err = %v, want fs.ErrNotExist", err)
122+ }
123+}
124+
125+func TestAMissingFileIsReportedAsNotExistByBothImplementations(t *testing.T) {
126+ for name, system := range map[string]fsys.FS{
127+ "local": fsys.Local{},
128+ "memory": fsystest.New("/"),
129+ } {
130+ missing := filepath.Join(t.TempDir(), "nope")
131+ if _, err := system.ReadFile(missing); !errors.Is(err, fs.ErrNotExist) {
132+ t.Errorf("%s ReadFile: %v, want fs.ErrNotExist", name, err)
133+ }
134+ if _, err := system.Stat(missing); !errors.Is(err, fs.ErrNotExist) {
135+ t.Errorf("%s Stat: %v, want fs.ErrNotExist", name, err)
136+ }
137+ }
138+}
139+
140+func ExampleUse() {
141+ remote := fsystest.New("/home/bob/project")
142+ remote.AddFile("/home/bob/project/hello.py", "print('hello')\n")
143+
144+ previous := fsys.Use(remote)
145+ defer fsys.Use(previous)
146+
147+ data, _ := fsys.ReadFile("hello.py")
148+ fmt.Print(string(data))
149+ // Output: print('hello')
150+}
added fsys/fsystest/memory.go +186 -0
new file mode 100644
@@ -0,0 +1,186 @@
1+// Package fsystest provides an in-memory fsys.FS, for tests that must prove a
2+// package reads and writes through fsys and not straight to the disk.
3+//
4+// A test that uses it swaps it in with fsys.Use and puts the previous one
5+// back; a file found only in memory is proof the code under test went through
6+// fsys, because the disk never had it.
7+//
8+// memory := fsystest.New("/remote/project")
9+// memory.AddFile("/remote/project/main.go", "package main\n")
10+// t.Cleanup(func() { fsys.Use(fsys.Use(memory)) })
11+package fsystest
12+
13+import (
14+ "io/fs"
15+ "path"
16+ "sort"
17+ "strings"
18+ "sync"
19+ "time"
20+)
21+
22+// Memory is a file system held in a map. It is safe for concurrent use.
23+type Memory struct {
24+ mu sync.Mutex
25+ working string
26+ files map[string]*file
27+ dirs map[string]bool
28+}
29+
30+// file is one regular file: its bytes, its mode and when it was last written.
31+type file struct {
32+ data []byte
33+ mode fs.FileMode
34+ modified time.Time
35+}
36+
37+// New returns an empty file system whose working directory is working, which
38+// exists, as does every directory above it.
39+func New(working string) *Memory {
40+ m := &Memory{working: path.Clean(working), files: map[string]*file{}, dirs: map[string]bool{"/": true}}
41+ m.mkdirAll(m.working)
42+ return m
43+}
44+
45+// AddFile creates or replaces a file with mode 0644, making its directories.
46+func (m *Memory) AddFile(name, content string) {
47+ m.mu.Lock()
48+ defer m.mu.Unlock()
49+ name = m.abs(name)
50+ m.mkdirAll(path.Dir(name))
51+ m.write(name, []byte(content), 0o644)
52+}
53+
54+// File returns a file's content and whether it exists.
55+func (m *Memory) File(name string) (string, bool) {
56+ m.mu.Lock()
57+ defer m.mu.Unlock()
58+ f, ok := m.files[m.abs(name)]
59+ if !ok {
60+ return "", false
61+ }
62+ return string(f.data), true
63+}
64+
65+// Mode returns a file's permissions, or zero when it does not exist.
66+func (m *Memory) Mode(name string) fs.FileMode {
67+ m.mu.Lock()
68+ defer m.mu.Unlock()
69+ if f, ok := m.files[m.abs(name)]; ok {
70+ return f.mode
71+ }
72+ return 0
73+}
74+
75+// ReadFile implements fsys.FS.
76+func (m *Memory) ReadFile(name string) ([]byte, error) {
77+ m.mu.Lock()
78+ defer m.mu.Unlock()
79+ f, ok := m.files[m.abs(name)]
80+ if !ok {
81+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
82+ }
83+ return append([]byte(nil), f.data...), nil
84+}
85+
86+// WriteFileAtomic implements fsys.FS.
87+func (m *Memory) WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
88+ m.mu.Lock()
89+ defer m.mu.Unlock()
90+ name = m.abs(name)
91+ if !m.dirs[path.Dir(name)] {
92+ return &fs.PathError{Op: "write", Path: name, Err: fs.ErrNotExist}
93+ }
94+ m.write(name, data, perm)
95+ return nil
96+}
97+
98+// ReadDir implements fsys.FS.
99+func (m *Memory) ReadDir(name string) ([]fs.DirEntry, error) {
100+ m.mu.Lock()
101+ defer m.mu.Unlock()
102+ directory := m.abs(name)
103+ if !m.dirs[directory] {
104+ return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
105+ }
106+
107+ var entries []fs.DirEntry
108+ for child, f := range m.files {
109+ if path.Dir(child) == directory {
110+ entries = append(entries, fs.FileInfoToDirEntry(fileInfo{name: path.Base(child), size: int64(len(f.data)), mode: f.mode, modified: f.modified}))
111+ }
112+ }
113+ for child := range m.dirs {
114+ if child != directory && path.Dir(child) == directory {
115+ entries = append(entries, fs.FileInfoToDirEntry(fileInfo{name: path.Base(child), mode: fs.ModeDir | 0o755}))
116+ }
117+ }
118+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
119+ return entries, nil
120+}
121+
122+// Stat implements fsys.FS.
123+func (m *Memory) Stat(name string) (fs.FileInfo, error) {
124+ m.mu.Lock()
125+ defer m.mu.Unlock()
126+ absolute := m.abs(name)
127+ if f, ok := m.files[absolute]; ok {
128+ return fileInfo{name: path.Base(absolute), size: int64(len(f.data)), mode: f.mode, modified: f.modified}, nil
129+ }
130+ if m.dirs[absolute] {
131+ return fileInfo{name: path.Base(absolute), mode: fs.ModeDir | 0o755}, nil
132+ }
133+ return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
134+}
135+
136+// MkdirAll implements fsys.FS.
137+func (m *Memory) MkdirAll(name string, _ fs.FileMode) error {
138+ m.mu.Lock()
139+ defer m.mu.Unlock()
140+ m.mkdirAll(m.abs(name))
141+ return nil
142+}
143+
144+// Getwd implements fsys.FS.
145+func (m *Memory) Getwd() (string, error) { return m.working, nil }
146+
147+// abs resolves a path against the working directory. The caller holds mu.
148+func (m *Memory) abs(name string) string {
149+ if strings.HasPrefix(name, "/") {
150+ return path.Clean(name)
151+ }
152+ return path.Join(m.working, name)
153+}
154+
155+// mkdirAll records a directory and its parents. The caller holds mu.
156+func (m *Memory) mkdirAll(directory string) {
157+ for directory != "/" && directory != "." {
158+ m.dirs[directory] = true
159+ directory = path.Dir(directory)
160+ }
161+}
162+
163+// write stores a file, each write a nanosecond later than the last so that a
164+// watcher comparing stamps sees every change. The caller holds mu.
165+func (m *Memory) write(name string, data []byte, mode fs.FileMode) {
166+ stamp := time.Unix(0, 0)
167+ if previous, ok := m.files[name]; ok {
168+ stamp = previous.modified.Add(time.Nanosecond)
169+ }
170+ m.files[name] = &file{data: append([]byte(nil), data...), mode: mode, modified: stamp}
171+}
172+
173+// fileInfo is what Stat and ReadDir describe a file or directory with.
174+type fileInfo struct {
175+ name string
176+ size int64
177+ mode fs.FileMode
178+ modified time.Time
179+}
180+
181+func (i fileInfo) Name() string { return i.name }
182+func (i fileInfo) Size() int64 { return i.size }
183+func (i fileInfo) Mode() fs.FileMode { return i.mode }
184+func (i fileInfo) ModTime() time.Time { return i.modified }
185+func (i fileInfo) IsDir() bool { return i.mode.IsDir() }
186+func (i fileInfo) Sys() any { return nil }
new file mode 100644
@@ -0,0 +1,186 @@
1+// Package fsystest provides an in-memory fsys.FS, for tests that must prove a
2+// package reads and writes through fsys and not straight to the disk.
3+//
4+// A test that uses it swaps it in with fsys.Use and puts the previous one
5+// back; a file found only in memory is proof the code under test went through
6+// fsys, because the disk never had it.
7+//
8+// memory := fsystest.New("/remote/project")
9+// memory.AddFile("/remote/project/main.go", "package main\n")
10+// t.Cleanup(func() { fsys.Use(fsys.Use(memory)) })
11+package fsystest
12+
13+import (
14+ "io/fs"
15+ "path"
16+ "sort"
17+ "strings"
18+ "sync"
19+ "time"
20+)
21+
22+// Memory is a file system held in a map. It is safe for concurrent use.
23+type Memory struct {
24+ mu sync.Mutex
25+ working string
26+ files map[string]*file
27+ dirs map[string]bool
28+}
29+
30+// file is one regular file: its bytes, its mode and when it was last written.
31+type file struct {
32+ data []byte
33+ mode fs.FileMode
34+ modified time.Time
35+}
36+
37+// New returns an empty file system whose working directory is working, which
38+// exists, as does every directory above it.
39+func New(working string) *Memory {
40+ m := &Memory{working: path.Clean(working), files: map[string]*file{}, dirs: map[string]bool{"/": true}}
41+ m.mkdirAll(m.working)
42+ return m
43+}
44+
45+// AddFile creates or replaces a file with mode 0644, making its directories.
46+func (m *Memory) AddFile(name, content string) {
47+ m.mu.Lock()
48+ defer m.mu.Unlock()
49+ name = m.abs(name)
50+ m.mkdirAll(path.Dir(name))
51+ m.write(name, []byte(content), 0o644)
52+}
53+
54+// File returns a file's content and whether it exists.
55+func (m *Memory) File(name string) (string, bool) {
56+ m.mu.Lock()
57+ defer m.mu.Unlock()
58+ f, ok := m.files[m.abs(name)]
59+ if !ok {
60+ return "", false
61+ }
62+ return string(f.data), true
63+}
64+
65+// Mode returns a file's permissions, or zero when it does not exist.
66+func (m *Memory) Mode(name string) fs.FileMode {
67+ m.mu.Lock()
68+ defer m.mu.Unlock()
69+ if f, ok := m.files[m.abs(name)]; ok {
70+ return f.mode
71+ }
72+ return 0
73+}
74+
75+// ReadFile implements fsys.FS.
76+func (m *Memory) ReadFile(name string) ([]byte, error) {
77+ m.mu.Lock()
78+ defer m.mu.Unlock()
79+ f, ok := m.files[m.abs(name)]
80+ if !ok {
81+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
82+ }
83+ return append([]byte(nil), f.data...), nil
84+}
85+
86+// WriteFileAtomic implements fsys.FS.
87+func (m *Memory) WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
88+ m.mu.Lock()
89+ defer m.mu.Unlock()
90+ name = m.abs(name)
91+ if !m.dirs[path.Dir(name)] {
92+ return &fs.PathError{Op: "write", Path: name, Err: fs.ErrNotExist}
93+ }
94+ m.write(name, data, perm)
95+ return nil
96+}
97+
98+// ReadDir implements fsys.FS.
99+func (m *Memory) ReadDir(name string) ([]fs.DirEntry, error) {
100+ m.mu.Lock()
101+ defer m.mu.Unlock()
102+ directory := m.abs(name)
103+ if !m.dirs[directory] {
104+ return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
105+ }
106+
107+ var entries []fs.DirEntry
108+ for child, f := range m.files {
109+ if path.Dir(child) == directory {
110+ entries = append(entries, fs.FileInfoToDirEntry(fileInfo{name: path.Base(child), size: int64(len(f.data)), mode: f.mode, modified: f.modified}))
111+ }
112+ }
113+ for child := range m.dirs {
114+ if child != directory && path.Dir(child) == directory {
115+ entries = append(entries, fs.FileInfoToDirEntry(fileInfo{name: path.Base(child), mode: fs.ModeDir | 0o755}))
116+ }
117+ }
118+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
119+ return entries, nil
120+}
121+
122+// Stat implements fsys.FS.
123+func (m *Memory) Stat(name string) (fs.FileInfo, error) {
124+ m.mu.Lock()
125+ defer m.mu.Unlock()
126+ absolute := m.abs(name)
127+ if f, ok := m.files[absolute]; ok {
128+ return fileInfo{name: path.Base(absolute), size: int64(len(f.data)), mode: f.mode, modified: f.modified}, nil
129+ }
130+ if m.dirs[absolute] {
131+ return fileInfo{name: path.Base(absolute), mode: fs.ModeDir | 0o755}, nil
132+ }
133+ return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
134+}
135+
136+// MkdirAll implements fsys.FS.
137+func (m *Memory) MkdirAll(name string, _ fs.FileMode) error {
138+ m.mu.Lock()
139+ defer m.mu.Unlock()
140+ m.mkdirAll(m.abs(name))
141+ return nil
142+}
143+
144+// Getwd implements fsys.FS.
145+func (m *Memory) Getwd() (string, error) { return m.working, nil }
146+
147+// abs resolves a path against the working directory. The caller holds mu.
148+func (m *Memory) abs(name string) string {
149+ if strings.HasPrefix(name, "/") {
150+ return path.Clean(name)
151+ }
152+ return path.Join(m.working, name)
153+}
154+
155+// mkdirAll records a directory and its parents. The caller holds mu.
156+func (m *Memory) mkdirAll(directory string) {
157+ for directory != "/" && directory != "." {
158+ m.dirs[directory] = true
159+ directory = path.Dir(directory)
160+ }
161+}
162+
163+// write stores a file, each write a nanosecond later than the last so that a
164+// watcher comparing stamps sees every change. The caller holds mu.
165+func (m *Memory) write(name string, data []byte, mode fs.FileMode) {
166+ stamp := time.Unix(0, 0)
167+ if previous, ok := m.files[name]; ok {
168+ stamp = previous.modified.Add(time.Nanosecond)
169+ }
170+ m.files[name] = &file{data: append([]byte(nil), data...), mode: mode, modified: stamp}
171+}
172+
173+// fileInfo is what Stat and ReadDir describe a file or directory with.
174+type fileInfo struct {
175+ name string
176+ size int64
177+ mode fs.FileMode
178+ modified time.Time
179+}
180+
181+func (i fileInfo) Name() string { return i.name }
182+func (i fileInfo) Size() int64 { return i.size }
183+func (i fileInfo) Mode() fs.FileMode { return i.mode }
184+func (i fileInfo) ModTime() time.Time { return i.modified }
185+func (i fileInfo) IsDir() bool { return i.mode.IsDir() }
186+func (i fileInfo) Sys() any { return nil }
added fsys/fsystest/memory_test.go +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+package fsystest
2+
3+import (
4+ "errors"
5+ "io/fs"
6+ "testing"
7+)
8+
9+func TestEachWriteMovesTheModificationTimeOn(t *testing.T) {
10+ memory := New("/p")
11+ memory.AddFile("a", "1")
12+ before, _ := memory.Stat("a")
13+ if err := memory.WriteFileAtomic("a", []byte("2"), 0o644); err != nil {
14+ t.Fatal(err)
15+ }
16+ after, _ := memory.Stat("a")
17+ if !after.ModTime().After(before.ModTime()) {
18+ t.Errorf("ModTime did not move: %v then %v", before.ModTime(), after.ModTime())
19+ }
20+}
21+
22+func TestWritingIntoAMissingDirectoryFails(t *testing.T) {
23+ memory := New("/p")
24+ if err := memory.WriteFileAtomic("/p/nope/a", []byte("x"), 0o644); !errors.Is(err, fs.ErrNotExist) {
25+ t.Errorf("err = %v, want fs.ErrNotExist", err)
26+ }
27+}
28+
29+func TestModeIsWhatTheWriteAskedFor(t *testing.T) {
30+ memory := New("/p")
31+ if err := memory.WriteFileAtomic("a", []byte("x"), 0o600); err != nil {
32+ t.Fatal(err)
33+ }
34+ if memory.Mode("a") != 0o600 {
35+ t.Errorf("Mode = %v, want 0600", memory.Mode("a"))
36+ }
37+ if memory.Mode("missing") != 0 {
38+ t.Errorf("Mode of a missing file = %v, want 0", memory.Mode("missing"))
39+ }
40+}
41+
42+func TestReadDirOfAMissingDirectoryFails(t *testing.T) {
43+ if _, err := New("/p").ReadDir("/q"); !errors.Is(err, fs.ErrNotExist) {
44+ t.Errorf("err = %v, want fs.ErrNotExist", err)
45+ }
46+}
new file mode 100644
@@ -0,0 +1,46 @@
1+package fsystest
2+
3+import (
4+ "errors"
5+ "io/fs"
6+ "testing"
7+)
8+
9+func TestEachWriteMovesTheModificationTimeOn(t *testing.T) {
10+ memory := New("/p")
11+ memory.AddFile("a", "1")
12+ before, _ := memory.Stat("a")
13+ if err := memory.WriteFileAtomic("a", []byte("2"), 0o644); err != nil {
14+ t.Fatal(err)
15+ }
16+ after, _ := memory.Stat("a")
17+ if !after.ModTime().After(before.ModTime()) {
18+ t.Errorf("ModTime did not move: %v then %v", before.ModTime(), after.ModTime())
19+ }
20+}
21+
22+func TestWritingIntoAMissingDirectoryFails(t *testing.T) {
23+ memory := New("/p")
24+ if err := memory.WriteFileAtomic("/p/nope/a", []byte("x"), 0o644); !errors.Is(err, fs.ErrNotExist) {
25+ t.Errorf("err = %v, want fs.ErrNotExist", err)
26+ }
27+}
28+
29+func TestModeIsWhatTheWriteAskedFor(t *testing.T) {
30+ memory := New("/p")
31+ if err := memory.WriteFileAtomic("a", []byte("x"), 0o600); err != nil {
32+ t.Fatal(err)
33+ }
34+ if memory.Mode("a") != 0o600 {
35+ t.Errorf("Mode = %v, want 0600", memory.Mode("a"))
36+ }
37+ if memory.Mode("missing") != 0 {
38+ t.Errorf("Mode of a missing file = %v, want 0", memory.Mode("missing"))
39+ }
40+}
41+
42+func TestReadDirOfAMissingDirectoryFails(t *testing.T) {
43+ if _, err := New("/p").ReadDir("/q"); !errors.Is(err, fs.ErrNotExist) {
44+ t.Errorf("err = %v, want fs.ErrNotExist", err)
45+ }
46+}
added fsys/local.go +64 -0
new file mode 100644
@@ -0,0 +1,64 @@
1+package fsys
2+
3+import (
4+ "io/fs"
5+ "os"
6+ "path/filepath"
7+)
8+
9+// Local is the disk of the machine the editor runs on, through package os.
10+// It is the default, and what every editor used before -ssh existed.
11+//
12+// fsys.Use(fsys.Local{})
13+type Local struct{}
14+
15+// ReadFile is os.ReadFile.
16+func (Local) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
17+
18+// ReadDir is os.ReadDir.
19+func (Local) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) }
20+
21+// Stat is os.Stat.
22+func (Local) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
23+
24+// MkdirAll is os.MkdirAll.
25+func (Local) MkdirAll(name string, perm fs.FileMode) error { return os.MkdirAll(name, perm) }
26+
27+// Getwd is os.Getwd.
28+func (Local) Getwd() (string, error) { return os.Getwd() }
29+
30+// WriteFileAtomic writes to a temporary file beside name and renames it over
31+// name. The temporary shares the directory because a rename is only atomic
32+// within one file system; its name starts with a dot and the target's name so
33+// that one left behind by a crash is recognisable.
34+func (Local) WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
35+ temporary, err := os.CreateTemp(filepath.Dir(name), "."+filepath.Base(name)+".*")
36+ if err != nil {
37+ return err
38+ }
39+ temporaryName := temporary.Name()
40+
41+ // From here on every failure must remove the temporary file, or a failed
42+ // save would litter the user's directory. After the rename this is a no-op.
43+ defer os.Remove(temporaryName)
44+
45+ if err := writeAndClose(temporary, data); err != nil {
46+ return err
47+ }
48+ // CreateTemp makes the file readable by its owner only, and the rename
49+ // would carry that over to the file being replaced.
50+ if err := os.Chmod(temporaryName, perm); err != nil {
51+ return err
52+ }
53+ return os.Rename(temporaryName, name)
54+}
55+
56+// writeAndClose writes a file and closes it, closing it either way. The close
57+// is what reports a write that failed on its way out, so its error counts.
58+func writeAndClose(f *os.File, data []byte) error {
59+ if _, err := f.Write(data); err != nil {
60+ f.Close()
61+ return err
62+ }
63+ return f.Close()
64+}
new file mode 100644
@@ -0,0 +1,64 @@
1+package fsys
2+
3+import (
4+ "io/fs"
5+ "os"
6+ "path/filepath"
7+)
8+
9+// Local is the disk of the machine the editor runs on, through package os.
10+// It is the default, and what every editor used before -ssh existed.
11+//
12+// fsys.Use(fsys.Local{})
13+type Local struct{}
14+
15+// ReadFile is os.ReadFile.
16+func (Local) ReadFile(name string) ([]byte, error) { return os.ReadFile(name) }
17+
18+// ReadDir is os.ReadDir.
19+func (Local) ReadDir(name string) ([]fs.DirEntry, error) { return os.ReadDir(name) }
20+
21+// Stat is os.Stat.
22+func (Local) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
23+
24+// MkdirAll is os.MkdirAll.
25+func (Local) MkdirAll(name string, perm fs.FileMode) error { return os.MkdirAll(name, perm) }
26+
27+// Getwd is os.Getwd.
28+func (Local) Getwd() (string, error) { return os.Getwd() }
29+
30+// WriteFileAtomic writes to a temporary file beside name and renames it over
31+// name. The temporary shares the directory because a rename is only atomic
32+// within one file system; its name starts with a dot and the target's name so
33+// that one left behind by a crash is recognisable.
34+func (Local) WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
35+ temporary, err := os.CreateTemp(filepath.Dir(name), "."+filepath.Base(name)+".*")
36+ if err != nil {
37+ return err
38+ }
39+ temporaryName := temporary.Name()
40+
41+ // From here on every failure must remove the temporary file, or a failed
42+ // save would litter the user's directory. After the rename this is a no-op.
43+ defer os.Remove(temporaryName)
44+
45+ if err := writeAndClose(temporary, data); err != nil {
46+ return err
47+ }
48+ // CreateTemp makes the file readable by its owner only, and the rename
49+ // would carry that over to the file being replaced.
50+ if err := os.Chmod(temporaryName, perm); err != nil {
51+ return err
52+ }
53+ return os.Rename(temporaryName, name)
54+}
55+
56+// writeAndClose writes a file and closes it, closing it either way. The close
57+// is what reports a write that failed on its way out, so its error counts.
58+func writeAndClose(f *os.File, data []byte) error {
59+ if _, err := f.Write(data); err != nil {
60+ f.Close()
61+ return err
62+ }
63+ return f.Close()
64+}
modified go.mod +6 -3
@@ -5,13 +5,16 @@ go 1.26.1
55 require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/gdamore/tcell/v2 v2.13.10
8- golang.org/x/sys v0.38.0
8+ github.com/pkg/sftp v1.13.11
9+ golang.org/x/sys v0.47.0
910 )
1011
1112 require (
1213 github.com/gdamore/encoding v1.0.1 // indirect
14+ github.com/kr/fs v0.1.0 // indirect
1315 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
1416 github.com/rivo/uniseg v0.4.7 // indirect
15- golang.org/x/term v0.37.0 // indirect
16- golang.org/x/text v0.31.0 // indirect
17+ golang.org/x/crypto v0.54.0 // indirect
18+ golang.org/x/term v0.45.0 // indirect
19+ golang.org/x/text v0.40.0 // indirect
1720 )
@@ -5,13 +5,16 @@ go 1.26.1
5 require (5 require (
6 github.com/BurntSushi/toml v1.6.06 github.com/BurntSushi/toml v1.6.0
7 github.com/gdamore/tcell/v2 v2.13.107 github.com/gdamore/tcell/v2 v2.13.10
8- golang.org/x/sys v0.38.08+ github.com/pkg/sftp v1.13.11
9+ golang.org/x/sys v0.47.0
9 )10 )
10 11
11 require (12 require (
12 github.com/gdamore/encoding v1.0.1 // indirect13 github.com/gdamore/encoding v1.0.1 // indirect
14+ github.com/kr/fs v0.1.0 // indirect
13 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect15 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
14 github.com/rivo/uniseg v0.4.7 // indirect16 github.com/rivo/uniseg v0.4.7 // indirect
15- golang.org/x/term v0.37.0 // indirect17+ golang.org/x/crypto v0.54.0 // indirect
16- golang.org/x/text v0.31.0 // indirect18+ golang.org/x/term v0.45.0 // indirect
19+ golang.org/x/text v0.40.0 // indirect
17 )20 )
modified go.sum +20 -6
@@ -1,16 +1,28 @@
11 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
22 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
35 github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
46 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
57 github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
68 github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
9+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
10+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
711 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
812 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
13+github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
14+github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
15+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
16+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
917 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
1018 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
19+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
20+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
1121 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
1222 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
1323 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
24+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
25+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
1426 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
1527 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
1628 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -26,22 +38,24 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
2638 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
2739 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
2840 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
30-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
41+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
42+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
3143 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
3244 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
3345 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
34-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
35-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
46+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
47+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
3648 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
3749 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
3850 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
3951 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
4052 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
41-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
42-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
53+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
54+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
4355 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
4456 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
4557 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
4658 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
4759 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
60+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
61+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1,16 +1,28 @@
1 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=1 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=2 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=5 github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
4 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=6 github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
5 github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=7 github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
6 github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=8 github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
9+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
10+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
7 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=11 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
8 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=12 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
13+github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
14+github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
15+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
16+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
9 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=17 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
10 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=18 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
19+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
20+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
11 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=21 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
12 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=22 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
13 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=23 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
24+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
25+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
14 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=26 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
15 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=27 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
16 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=28 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -26,22 +38,24 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
26 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=38 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
27 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=39 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
28 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=40 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=41+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
30-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=42+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
31 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=43 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
32 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=44 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
33 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=45 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
34-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=46+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
35-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=47+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
36 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=48 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
37 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=49 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
38 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=50 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
39 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=51 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
40 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=52 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
41-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=53+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
42-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=54+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
43 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=55 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
44 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=56 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
45 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=57 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
46 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=58 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
47 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=59 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
60+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
61+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modified projectfile/README.md +2 -0
@@ -22,6 +22,8 @@ The write goes to a temporary file **in the same directory** as its destination,
2222
2323 The destination's directory is created if it is not there, so a caller does not have to.
2424
25+Both the directory and the write go through `fsys` (`MkdirAll`, `WriteFileAtomic`), so under `-ssh` the file is written on the remote machine, with the same temporary-then-rename guarantee over SFTP.
26+
2527 ## Public API
2628
2729 | Name | What it does |
@@ -22,6 +22,8 @@ The write goes to a temporary file **in the same directory** as its destination,
22 22
23 The destination's directory is created if it is not there, so a caller does not have to.23 The destination's directory is created if it is not there, so a caller does not have to.
24 24
25+Both the directory and the write go through `fsys` (`MkdirAll`, `WriteFileAtomic`), so under `-ssh` the file is written on the remote machine, with the same temporary-then-rename guarantee over SFTP.
26+
25 ## Public API27 ## Public API
26 28
27 | Name | What it does |29 | Name | What it does |
added projectfile/fsys_test.go +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+package projectfile
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestWriteGoesThroughTheCurrentFileSystemAndMakesTheDirectory(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+
15+ if err := Write("/remote/project/.turbo-test/tools.toml", []byte("[[tool]]\n")); err != nil {
16+ t.Fatal(err)
17+ }
18+ if got, ok := memory.File("/remote/project/.turbo-test/tools.toml"); !ok || got != "[[tool]]\n" {
19+ t.Errorf("file = %q, %v", got, ok)
20+ }
21+ if mode := memory.Mode("/remote/project/.turbo-test/tools.toml"); mode != fileMode {
22+ t.Errorf("mode = %v, want %v", mode, fileMode)
23+ }
24+}
new file mode 100644
@@ -0,0 +1,24 @@
1+package projectfile
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestWriteGoesThroughTheCurrentFileSystemAndMakesTheDirectory(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+
15+ if err := Write("/remote/project/.turbo-test/tools.toml", []byte("[[tool]]\n")); err != nil {
16+ t.Fatal(err)
17+ }
18+ if got, ok := memory.File("/remote/project/.turbo-test/tools.toml"); !ok || got != "[[tool]]\n" {
19+ t.Errorf("file = %q, %v", got, ok)
20+ }
21+ if mode := memory.Mode("/remote/project/.turbo-test/tools.toml"); mode != fileMode {
22+ t.Errorf("mode = %v, want %v", mode, fileMode)
23+ }
24+}
modified projectfile/projectfile.go +6 -33
@@ -13,8 +13,9 @@ package projectfile
1313
1414 import (
1515 "fmt"
16- "os"
1716 "path/filepath"
17+
18+ "rickub.com/turbo-editors/turbo-core/fsys"
1819 )
1920
2021 // fileMode is what a project's configuration file is created with: readable by
@@ -45,39 +46,11 @@ func Write(path string, data []byte) error {
4546 return nil
4647 }
4748
48-// write does the work, leaving the error to be named by Write.
49+// write does the work, leaving the error to be named by Write. It goes through
50+// fsys, so under -ssh the project's files are written on the remote machine.
4951 func write(path string, data []byte) error {
50- directory := filepath.Dir(path)
51- if err := os.MkdirAll(directory, dirMode); err != nil {
52- return err
53- }
54-
55- temporary, err := os.CreateTemp(directory, ".projectfile-*.toml")
56- if err != nil {
57- return err
58- }
59- name := temporary.Name()
60- defer os.Remove(name) // a no-op once the rename below has succeeded
61-
62- if err := writeAndClose(temporary, data); err != nil {
63- return err
64- }
65- // CreateTemp makes the file readable by its owner only, and the rename
66- // would carry that through to a file a team is meant to share.
67- if err := os.Chmod(name, fileMode); err != nil {
68- return err
69- }
70- return os.Rename(name, path)
71-}
72-
73-// writeAndClose writes a file and closes it, closing it either way.
74-//
75-// The close is what reports a write that failed on its way out of the buffer,
76-// so its error matters as much as the write's.
77-func writeAndClose(f *os.File, data []byte) error {
78- if _, err := f.Write(data); err != nil {
79- f.Close()
52+ if err := fsys.MkdirAll(filepath.Dir(path), dirMode); err != nil {
8053 return err
8154 }
82- return f.Close()
55+ return fsys.WriteFileAtomic(path, data, fileMode)
8356 }
@@ -13,8 +13,9 @@ package projectfile
13 13
14 import (14 import (
15 "fmt"15 "fmt"
16- "os"
17 "path/filepath"16 "path/filepath"
17+
18+ "rickub.com/turbo-editors/turbo-core/fsys"
18 )19 )
19 20
20 // fileMode is what a project's configuration file is created with: readable by21 // fileMode is what a project's configuration file is created with: readable by
@@ -45,39 +46,11 @@ func Write(path string, data []byte) error {
45 return nil46 return nil
46 }47 }
47 48
48-// write does the work, leaving the error to be named by Write.49+// write does the work, leaving the error to be named by Write. It goes through
50+// fsys, so under -ssh the project's files are written on the remote machine.
49 func write(path string, data []byte) error {51 func write(path string, data []byte) error {
50- directory := filepath.Dir(path)52+ if err := fsys.MkdirAll(filepath.Dir(path), dirMode); err != nil {
51- if err := os.MkdirAll(directory, dirMode); err != nil {
52- return err
53- }
54-
55- temporary, err := os.CreateTemp(directory, ".projectfile-*.toml")
56- if err != nil {
57- return err
58- }
59- name := temporary.Name()
60- defer os.Remove(name) // a no-op once the rename below has succeeded
61-
62- if err := writeAndClose(temporary, data); err != nil {
63- return err
64- }
65- // CreateTemp makes the file readable by its owner only, and the rename
66- // would carry that through to a file a team is meant to share.
67- if err := os.Chmod(name, fileMode); err != nil {
68- return err
69- }
70- return os.Rename(name, path)
71-}
72-
73-// writeAndClose writes a file and closes it, closing it either way.
74-//
75-// The close is what reports a write that failed on its way out of the buffer,
76-// so its error matters as much as the write's.
77-func writeAndClose(f *os.File, data []byte) error {
78- if _, err := f.Write(data); err != nil {
79- f.Close()
80 return err53 return err
81 }54 }
82- return f.Close()55+ return fsys.WriteFileAtomic(path, data, fileMode)
83 }56 }
modified release.env +1 -1
@@ -6,5 +6,5 @@
66 #
77 # ./02-build-releases.sh v1.0.4 to see what the CI will stage, without
88 # publishing anything
9-TAG="v1.0.4"
9+TAG="v1.0.5"
1010 ABOUT="Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks"
@@ -6,5 +6,5 @@
6 #6 #
7 # ./02-build-releases.sh v1.0.4 to see what the CI will stage, without7 # ./02-build-releases.sh v1.0.4 to see what the CI will stage, without
8 # publishing anything8 # publishing anything
9-TAG="v1.0.4"9+TAG="v1.0.5"
10 ABOUT="Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks"10 ABOUT="Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks"
added remote/README.md +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+# remote
2+
3+Another machine, reached through the `ssh` command already installed: one shared connection, the files over SFTP, and the command lines that open a shell or run a tool over there. It is what every editor's `-ssh user@machine[:directory]` flag uses.
4+
5+```go
6+session, err := remote.Open(ctx, "bob@build-box:/srv/app")
7+if err != nil {
8+ return err
9+}
10+defer session.Close()
11+fsys.Use(session.FS())
12+editor.UseRemote(session)
13+```
14+
15+Imports the standard library and `github.com/pkg/sftp`. Its `FS` satisfies `fsys.FS` without importing `fsys`, and `Session` satisfies `app.Remote` without importing `app`.
16+
17+## Why the ssh command, not an SSH library
18+
19+The installed command already knows everything the person has set up: `~/.ssh/config` and its `Host` aliases, ports, `ProxyJump`, the agent, `known_hosts`, passphrases, passwords. A library would have to reimplement each of those, the host-key question first. The same reasoning made `configrepo` run `git`.
20+
21+## One login, then everything through it
22+
23+`Connect` runs `ssh -o ControlMaster=yes -o ControlPersist=60 -N -f` **in the foreground, before the editor takes the terminal**: that is where ssh asks for a password or whether to trust a new host. With `-f` it returns once logged in, leaving the connection behind a control socket in a private temporary directory.
24+
25+Everything afterwards passes `-S <socket> -o ControlMaster=no -o BatchMode=yes`: it goes through that connection, never opens its own, and never prompts — a prompt then would be drawn over the editor.
26+
27+- **Files**: one `ssh -s … sftp` channel for the whole session, spoken to with `pkg/sftp`. A save writes `.<name>.<random>` beside the target and renames it over with `posix-rename@openssh.com`.
28+- **A terminal**: `ssh -t … "exec /bin/sh -c 'cd <dir> && exec \"${SHELL:-/bin/sh}\" -l'"`.
29+- **A tool**: `ssh -T` (captured) or `-t` (in a terminal) `… "exec /bin/sh -c 'cd <dir> && <command>'"`. Always `/bin/sh`, whatever the user's login shell is, so a tools file means the same thing on both machines.
30+
31+`Close` closes the file channel, sends `-O exit` to the master, and removes the socket's directory.
32+
33+## Limits
34+
35+- **Windows**: its OpenSSH has no ControlMaster; `Connect` returns `ErrUnsupported`.
36+- **No port in the target**: put it in `~/.ssh/config` under a `Host` alias, as for scp.
37+- **No language server and no agents** over ssh; the editor switches both off. See `docs/*/explanation/remote-editing.md`.
38+
39+## Public API
40+
41+| Name | What it does |
42+| --- | --- |
43+| `ParseTarget(arg) (Target, error)` | `[user@]host[:path]`; refuses a host or user starting with `-` (`ErrBadTarget`) |
44+| `Target.Destination()`, `Target.String()` | `user@host`; the target as typed |
45+| `Open(ctx, arg)` | Parse, say `Connecting to …` on stderr, `Connect` |
46+| `Connect(ctx, target, Options)` | Log in, open the file channel, resolve the directory |
47+| `Session.FS()` | The remote files, an `fsys.FS` rooted at the directory |
48+| `Session.Name()`, `Session.Target()` | How it is shown; what it connected to |
49+| `Session.ShellCommand(dir)`, `Session.RunCommand(dir, cmd, withTerminal)` | The argv of a remote shell or tool |
50+| `Session.Close()` | End it all; safe twice |
51+| `ErrBadTarget`, `ErrNoSSH`, `ErrUnsupported` | Bad argument; no ssh; Windows |
52+
53+## Tests
54+
55+```sh
56+go test ./remote/
57+```
58+
59+No network needed. The `FS` tests speak to a real SFTP server — `pkg/sftp`'s own — through in-memory pipes, serving a temporary directory. `Connect` and `Close` run end to end against the test binary itself standing in for ssh (`TestMain`): it logs every invocation, serves SFTP on `-s`, and can be told to fail the login or refuse the subsystem.
60+
61+Against a real sshd — the demo machine in `turbo-editors/demo-remote-machine`:
62+
63+```sh
64+TURBO_CORE_SSH_KEY=$PWD/../demo-remote-machine/keys/id_demo go test ./remote/ -run Real -v
65+```
new file mode 100644
@@ -0,0 +1,65 @@
1+# remote
2+
3+Another machine, reached through the `ssh` command already installed: one shared connection, the files over SFTP, and the command lines that open a shell or run a tool over there. It is what every editor's `-ssh user@machine[:directory]` flag uses.
4+
5+```go
6+session, err := remote.Open(ctx, "bob@build-box:/srv/app")
7+if err != nil {
8+ return err
9+}
10+defer session.Close()
11+fsys.Use(session.FS())
12+editor.UseRemote(session)
13+```
14+
15+Imports the standard library and `github.com/pkg/sftp`. Its `FS` satisfies `fsys.FS` without importing `fsys`, and `Session` satisfies `app.Remote` without importing `app`.
16+
17+## Why the ssh command, not an SSH library
18+
19+The installed command already knows everything the person has set up: `~/.ssh/config` and its `Host` aliases, ports, `ProxyJump`, the agent, `known_hosts`, passphrases, passwords. A library would have to reimplement each of those, the host-key question first. The same reasoning made `configrepo` run `git`.
20+
21+## One login, then everything through it
22+
23+`Connect` runs `ssh -o ControlMaster=yes -o ControlPersist=60 -N -f` **in the foreground, before the editor takes the terminal**: that is where ssh asks for a password or whether to trust a new host. With `-f` it returns once logged in, leaving the connection behind a control socket in a private temporary directory.
24+
25+Everything afterwards passes `-S <socket> -o ControlMaster=no -o BatchMode=yes`: it goes through that connection, never opens its own, and never prompts — a prompt then would be drawn over the editor.
26+
27+- **Files**: one `ssh -s … sftp` channel for the whole session, spoken to with `pkg/sftp`. A save writes `.<name>.<random>` beside the target and renames it over with `posix-rename@openssh.com`.
28+- **A terminal**: `ssh -t … "exec /bin/sh -c 'cd <dir> && exec \"${SHELL:-/bin/sh}\" -l'"`.
29+- **A tool**: `ssh -T` (captured) or `-t` (in a terminal) `… "exec /bin/sh -c 'cd <dir> && <command>'"`. Always `/bin/sh`, whatever the user's login shell is, so a tools file means the same thing on both machines.
30+
31+`Close` closes the file channel, sends `-O exit` to the master, and removes the socket's directory.
32+
33+## Limits
34+
35+- **Windows**: its OpenSSH has no ControlMaster; `Connect` returns `ErrUnsupported`.
36+- **No port in the target**: put it in `~/.ssh/config` under a `Host` alias, as for scp.
37+- **No language server and no agents** over ssh; the editor switches both off. See `docs/*/explanation/remote-editing.md`.
38+
39+## Public API
40+
41+| Name | What it does |
42+| --- | --- |
43+| `ParseTarget(arg) (Target, error)` | `[user@]host[:path]`; refuses a host or user starting with `-` (`ErrBadTarget`) |
44+| `Target.Destination()`, `Target.String()` | `user@host`; the target as typed |
45+| `Open(ctx, arg)` | Parse, say `Connecting to …` on stderr, `Connect` |
46+| `Connect(ctx, target, Options)` | Log in, open the file channel, resolve the directory |
47+| `Session.FS()` | The remote files, an `fsys.FS` rooted at the directory |
48+| `Session.Name()`, `Session.Target()` | How it is shown; what it connected to |
49+| `Session.ShellCommand(dir)`, `Session.RunCommand(dir, cmd, withTerminal)` | The argv of a remote shell or tool |
50+| `Session.Close()` | End it all; safe twice |
51+| `ErrBadTarget`, `ErrNoSSH`, `ErrUnsupported` | Bad argument; no ssh; Windows |
52+
53+## Tests
54+
55+```sh
56+go test ./remote/
57+```
58+
59+No network needed. The `FS` tests speak to a real SFTP server — `pkg/sftp`'s own — through in-memory pipes, serving a temporary directory. `Connect` and `Close` run end to end against the test binary itself standing in for ssh (`TestMain`): it logs every invocation, serves SFTP on `-s`, and can be told to fail the login or refuse the subsystem.
60+
61+Against a real sshd — the demo machine in `turbo-editors/demo-remote-machine`:
62+
63+```sh
64+TURBO_CORE_SSH_KEY=$PWD/../demo-remote-machine/keys/id_demo go test ./remote/ -run Real -v
65+```
added remote/files.go +144 -0
new file mode 100644
@@ -0,0 +1,144 @@
1+package remote
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "io/fs"
10+ "os"
11+ "path"
12+ "sort"
13+
14+ "github.com/pkg/sftp"
15+)
16+
17+// FS is a machine's files over SFTP: an fsys.FS whose relative paths start
18+// from the directory given after the colon of the target.
19+//
20+// Every method is one or a few SFTP requests on the session's one channel,
21+// which is what makes the editor's once-a-second look at its open files cheap
22+// enough to keep doing.
23+//
24+// fsys.Use(session.FS())
25+type FS struct {
26+ client *sftp.Client
27+ working string
28+}
29+
30+// newFS returns the files a client reaches, relative to working.
31+func newFS(client *sftp.Client, working string) *FS {
32+ return &FS{client: client, working: working}
33+}
34+
35+// abs resolves a path against the working directory. The remote machine is a
36+// Unix machine — an SFTP server's paths are slash-separated whatever the
37+// platform the editor runs on — so this is package path, not path/filepath.
38+func (f *FS) abs(name string) string {
39+ if path.IsAbs(name) {
40+ return path.Clean(name)
41+ }
42+ return path.Join(f.working, name)
43+}
44+
45+// Getwd returns the directory being edited.
46+func (f *FS) Getwd() (string, error) { return f.working, nil }
47+
48+// ReadFile returns a remote file's content.
49+func (f *FS) ReadFile(name string) ([]byte, error) {
50+ file, err := f.client.Open(f.abs(name))
51+ if err != nil {
52+ return nil, notExist(err)
53+ }
54+ defer file.Close()
55+ return io.ReadAll(file)
56+}
57+
58+// Stat describes a remote file, following symbolic links.
59+func (f *FS) Stat(name string) (fs.FileInfo, error) {
60+ info, err := f.client.Stat(f.abs(name))
61+ return info, notExist(err)
62+}
63+
64+// ReadDir lists a remote directory, sorted by name as os.ReadDir sorts.
65+func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) {
66+ infos, err := f.client.ReadDir(f.abs(name))
67+ if err != nil {
68+ return nil, notExist(err)
69+ }
70+ sort.Slice(infos, func(i, j int) bool { return infos[i].Name() < infos[j].Name() })
71+
72+ entries := make([]fs.DirEntry, len(infos))
73+ for i, info := range infos {
74+ entries[i] = fs.FileInfoToDirEntry(info)
75+ }
76+ return entries, nil
77+}
78+
79+// MkdirAll makes a remote directory and its parents. SFTP's mkdir takes no
80+// permissions worth relying on, so the remote umask decides them, as it would
81+// for mkdir -p typed there.
82+func (f *FS) MkdirAll(name string, _ fs.FileMode) error {
83+ return f.client.MkdirAll(f.abs(name))
84+}
85+
86+// WriteFileAtomic writes to a temporary file beside name and renames it over
87+// name with OpenSSH's posix-rename, which replaces the target in one step as
88+// rename(2) does. A reader on the machine sees the old file or the new one.
89+func (f *FS) WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
90+ target := f.abs(name)
91+ temporary, err := temporaryBeside(target)
92+ if err != nil {
93+ return err
94+ }
95+
96+ file, err := f.client.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL)
97+ if err != nil {
98+ return notExist(err)
99+ }
100+ // Every failure from here on must remove the temporary file, or a failed
101+ // save would litter the remote directory. After the rename, a no-op.
102+ defer f.client.Remove(temporary) //nolint:errcheck // best effort, see above
103+
104+ if err := writeAndClose(file, data); err != nil {
105+ return err
106+ }
107+ if err := f.client.Chmod(temporary, perm); err != nil {
108+ return err
109+ }
110+ return f.client.PosixRename(temporary, target)
111+}
112+
113+// temporaryBeside names a temporary file in the same directory as target:
114+// .<name>.<random>, as the local file system names its own.
115+func temporaryBeside(target string) (string, error) {
116+ random := make([]byte, 6)
117+ if _, err := rand.Read(random); err != nil {
118+ return "", fmt.Errorf("naming a temporary file: %w", err)
119+ }
120+ return path.Join(path.Dir(target), "."+path.Base(target)+"."+hex.EncodeToString(random)), nil
121+}
122+
123+// writeAndClose writes a remote file and closes it, closing it either way.
124+func writeAndClose(file *sftp.File, data []byte) error {
125+ if _, err := file.Write(data); err != nil {
126+ file.Close()
127+ return err
128+ }
129+ return file.Close()
130+}
131+
132+// notExist makes sure a missing file is reported as fs.ErrNotExist, which is
133+// how every caller of fsys recognises "a new file" — whatever form the SFTP
134+// server's status took on the way here.
135+func notExist(err error) error {
136+ if err == nil || errors.Is(err, fs.ErrNotExist) {
137+ return err
138+ }
139+ var status *sftp.StatusError
140+ if errors.As(err, &status) && status.FxCode() == sftp.ErrSSHFxNoSuchFile {
141+ return fmt.Errorf("%w: %w", fs.ErrNotExist, err)
142+ }
143+ return err
144+}
new file mode 100644
@@ -0,0 +1,144 @@
1+package remote
2+
3+import (
4+ "crypto/rand"
5+ "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "io/fs"
10+ "os"
11+ "path"
12+ "sort"
13+
14+ "github.com/pkg/sftp"
15+)
16+
17+// FS is a machine's files over SFTP: an fsys.FS whose relative paths start
18+// from the directory given after the colon of the target.
19+//
20+// Every method is one or a few SFTP requests on the session's one channel,
21+// which is what makes the editor's once-a-second look at its open files cheap
22+// enough to keep doing.
23+//
24+// fsys.Use(session.FS())
25+type FS struct {
26+ client *sftp.Client
27+ working string
28+}
29+
30+// newFS returns the files a client reaches, relative to working.
31+func newFS(client *sftp.Client, working string) *FS {
32+ return &FS{client: client, working: working}
33+}
34+
35+// abs resolves a path against the working directory. The remote machine is a
36+// Unix machine — an SFTP server's paths are slash-separated whatever the
37+// platform the editor runs on — so this is package path, not path/filepath.
38+func (f *FS) abs(name string) string {
39+ if path.IsAbs(name) {
40+ return path.Clean(name)
41+ }
42+ return path.Join(f.working, name)
43+}
44+
45+// Getwd returns the directory being edited.
46+func (f *FS) Getwd() (string, error) { return f.working, nil }
47+
48+// ReadFile returns a remote file's content.
49+func (f *FS) ReadFile(name string) ([]byte, error) {
50+ file, err := f.client.Open(f.abs(name))
51+ if err != nil {
52+ return nil, notExist(err)
53+ }
54+ defer file.Close()
55+ return io.ReadAll(file)
56+}
57+
58+// Stat describes a remote file, following symbolic links.
59+func (f *FS) Stat(name string) (fs.FileInfo, error) {
60+ info, err := f.client.Stat(f.abs(name))
61+ return info, notExist(err)
62+}
63+
64+// ReadDir lists a remote directory, sorted by name as os.ReadDir sorts.
65+func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) {
66+ infos, err := f.client.ReadDir(f.abs(name))
67+ if err != nil {
68+ return nil, notExist(err)
69+ }
70+ sort.Slice(infos, func(i, j int) bool { return infos[i].Name() < infos[j].Name() })
71+
72+ entries := make([]fs.DirEntry, len(infos))
73+ for i, info := range infos {
74+ entries[i] = fs.FileInfoToDirEntry(info)
75+ }
76+ return entries, nil
77+}
78+
79+// MkdirAll makes a remote directory and its parents. SFTP's mkdir takes no
80+// permissions worth relying on, so the remote umask decides them, as it would
81+// for mkdir -p typed there.
82+func (f *FS) MkdirAll(name string, _ fs.FileMode) error {
83+ return f.client.MkdirAll(f.abs(name))
84+}
85+
86+// WriteFileAtomic writes to a temporary file beside name and renames it over
87+// name with OpenSSH's posix-rename, which replaces the target in one step as
88+// rename(2) does. A reader on the machine sees the old file or the new one.
89+func (f *FS) WriteFileAtomic(name string, data []byte, perm fs.FileMode) error {
90+ target := f.abs(name)
91+ temporary, err := temporaryBeside(target)
92+ if err != nil {
93+ return err
94+ }
95+
96+ file, err := f.client.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL)
97+ if err != nil {
98+ return notExist(err)
99+ }
100+ // Every failure from here on must remove the temporary file, or a failed
101+ // save would litter the remote directory. After the rename, a no-op.
102+ defer f.client.Remove(temporary) //nolint:errcheck // best effort, see above
103+
104+ if err := writeAndClose(file, data); err != nil {
105+ return err
106+ }
107+ if err := f.client.Chmod(temporary, perm); err != nil {
108+ return err
109+ }
110+ return f.client.PosixRename(temporary, target)
111+}
112+
113+// temporaryBeside names a temporary file in the same directory as target:
114+// .<name>.<random>, as the local file system names its own.
115+func temporaryBeside(target string) (string, error) {
116+ random := make([]byte, 6)
117+ if _, err := rand.Read(random); err != nil {
118+ return "", fmt.Errorf("naming a temporary file: %w", err)
119+ }
120+ return path.Join(path.Dir(target), "."+path.Base(target)+"."+hex.EncodeToString(random)), nil
121+}
122+
123+// writeAndClose writes a remote file and closes it, closing it either way.
124+func writeAndClose(file *sftp.File, data []byte) error {
125+ if _, err := file.Write(data); err != nil {
126+ file.Close()
127+ return err
128+ }
129+ return file.Close()
130+}
131+
132+// notExist makes sure a missing file is reported as fs.ErrNotExist, which is
133+// how every caller of fsys recognises "a new file" — whatever form the SFTP
134+// server's status took on the way here.
135+func notExist(err error) error {
136+ if err == nil || errors.Is(err, fs.ErrNotExist) {
137+ return err
138+ }
139+ var status *sftp.StatusError
140+ if errors.As(err, &status) && status.FxCode() == sftp.ErrSSHFxNoSuchFile {
141+ return fmt.Errorf("%w: %w", fs.ErrNotExist, err)
142+ }
143+ return err
144+}
added remote/files_test.go +182 -0
new file mode 100644
@@ -0,0 +1,182 @@
1+package remote
2+
3+import (
4+ "errors"
5+ "io"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/pkg/sftp"
12+)
13+
14+// pipe joins two one-way pipes into the read-write stream an SFTP server
15+// talks over.
16+type pipe struct {
17+ io.Reader
18+ io.WriteCloser
19+}
20+
21+// newTestClient returns an SFTP client talking to a real SFTP server — the
22+// one pkg/sftp ships — through in-memory pipes. The server serves this
23+// machine's disk, so the test can check the outcome with package os.
24+func newTestClient(t *testing.T) *sftp.Client {
25+ t.Helper()
26+ toServer, fromClient := io.Pipe()
27+ toClient, fromServer := io.Pipe()
28+
29+ server, err := sftp.NewServer(pipe{toServer, fromServer}, sftp.WithServerWorkingDirectory(t.TempDir()))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ go server.Serve() //nolint:errcheck // ends when the client closes
34+
35+ client, err := sftp.NewClientPipe(toClient, fromClient)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ // The server goes first: closing its end is what lets the client's reading
40+ // goroutine see the end of the stream, and client.Close waits for it.
41+ t.Cleanup(func() { server.Close(); client.Close() })
42+ return client
43+}
44+
45+// newTestFS returns the remote file system rooted at a fresh directory.
46+func newTestFS(t *testing.T) (*FS, string) {
47+ t.Helper()
48+ root := t.TempDir()
49+ return newFS(newTestClient(t), root), root
50+}
51+
52+func TestReadFileReadsARemoteFileByRelativePath(t *testing.T) {
53+ files, root := newTestFS(t)
54+ if err := os.WriteFile(filepath.Join(root, "main.py"), []byte("print(1)\n"), 0o644); err != nil {
55+ t.Fatal(err)
56+ }
57+
58+ data, err := files.ReadFile("main.py")
59+ if err != nil || string(data) != "print(1)\n" {
60+ t.Errorf("ReadFile = %q, %v", data, err)
61+ }
62+}
63+
64+func TestAMissingRemoteFileIsReportedAsNotExist(t *testing.T) {
65+ files, _ := newTestFS(t)
66+ if _, err := files.ReadFile("nope.py"); !errors.Is(err, fs.ErrNotExist) {
67+ t.Errorf("ReadFile: %v, want fs.ErrNotExist", err)
68+ }
69+ if _, err := files.Stat("nope.py"); !errors.Is(err, fs.ErrNotExist) {
70+ t.Errorf("Stat: %v, want fs.ErrNotExist", err)
71+ }
72+ if _, err := files.ReadDir("nope"); !errors.Is(err, fs.ErrNotExist) {
73+ t.Errorf("ReadDir: %v, want fs.ErrNotExist", err)
74+ }
75+}
76+
77+func TestWriteFileAtomicReplacesTheFileWithTheGivenModeAndLeavesNothingBehind(t *testing.T) {
78+ files, root := newTestFS(t)
79+ target := filepath.Join(root, "run.sh")
80+ if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
81+ t.Fatal(err)
82+ }
83+
84+ if err := files.WriteFileAtomic("run.sh", []byte("new"), 0o755); err != nil {
85+ t.Fatal(err)
86+ }
87+
88+ data, _ := os.ReadFile(target)
89+ if string(data) != "new" {
90+ t.Errorf("content = %q, want new", data)
91+ }
92+ info, _ := os.Stat(target)
93+ if info.Mode().Perm() != 0o755 {
94+ t.Errorf("mode = %v, want 0755", info.Mode().Perm())
95+ }
96+ entries, _ := os.ReadDir(root)
97+ if len(entries) != 1 {
98+ t.Errorf("%d entries in the directory, want 1: a temporary file was left behind", len(entries))
99+ }
100+}
101+
102+func TestWriteFileAtomicIntoAMissingDirectoryIsNotExist(t *testing.T) {
103+ files, _ := newTestFS(t)
104+ if err := files.WriteFileAtomic("missing/a.py", []byte("x"), 0o644); !errors.Is(err, fs.ErrNotExist) {
105+ t.Errorf("err = %v, want fs.ErrNotExist", err)
106+ }
107+}
108+
109+func TestReadDirListsSortedByName(t *testing.T) {
110+ files, root := newTestFS(t)
111+ for _, name := range []string{"b.py", "a.py", "c.py"} {
112+ if err := os.WriteFile(filepath.Join(root, name), nil, 0o644); err != nil {
113+ t.Fatal(err)
114+ }
115+ }
116+ if err := os.Mkdir(filepath.Join(root, "pkg"), 0o755); err != nil {
117+ t.Fatal(err)
118+ }
119+
120+ entries, err := files.ReadDir(".")
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ var names []string
125+ for _, entry := range entries {
126+ names = append(names, entry.Name())
127+ }
128+ if len(names) != 4 || names[0] != "a.py" || names[3] != "pkg" || !entries[3].IsDir() {
129+ t.Errorf("entries = %v", names)
130+ }
131+}
132+
133+func TestMkdirAllAndStatWorkOnTheRemoteSide(t *testing.T) {
134+ files, root := newTestFS(t)
135+ if err := files.MkdirAll(".turbo-python/deep", 0o755); err != nil {
136+ t.Fatal(err)
137+ }
138+ info, err := files.Stat(".turbo-python/deep")
139+ if err != nil || !info.IsDir() {
140+ t.Errorf("Stat = %v, %v; want a directory", info, err)
141+ }
142+ if _, err := os.Stat(filepath.Join(root, ".turbo-python", "deep")); err != nil {
143+ t.Errorf("the directory is not on disk: %v", err)
144+ }
145+ if working, _ := files.Getwd(); working != root {
146+ t.Errorf("Getwd = %q, want %q", working, root)
147+ }
148+}
149+
150+func TestTheWorkingDirectoryIsResolvedFromTheTargetsPath(t *testing.T) {
151+ client := newTestClient(t)
152+ home, err := client.RealPath(".")
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ if err := os.Mkdir(filepath.Join(home, "app"), 0o755); err != nil {
157+ t.Fatal(err)
158+ }
159+ if err := os.WriteFile(filepath.Join(home, "file.txt"), nil, 0o644); err != nil {
160+ t.Fatal(err)
161+ }
162+
163+ for path, want := range map[string]string{
164+ "": home,
165+ "~": home,
166+ "~/app": filepath.Join(home, "app"),
167+ "app": filepath.Join(home, "app"),
168+ filepath.Join(home, "app"): filepath.Join(home, "app"),
169+ filepath.Join(home, "app/.."): home,
170+ } {
171+ if got, err := workingDirectory(client, path); err != nil || got != want {
172+ t.Errorf("workingDirectory(%q) = %q, %v; want %q", path, got, err, want)
173+ }
174+ }
175+
176+ if _, err := workingDirectory(client, "missing"); err == nil {
177+ t.Error("a missing directory was accepted")
178+ }
179+ if _, err := workingDirectory(client, "file.txt"); !errors.Is(err, fs.ErrInvalid) {
180+ t.Errorf("a file as the directory: %v, want fs.ErrInvalid", err)
181+ }
182+}
new file mode 100644
@@ -0,0 +1,182 @@
1+package remote
2+
3+import (
4+ "errors"
5+ "io"
6+ "io/fs"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/pkg/sftp"
12+)
13+
14+// pipe joins two one-way pipes into the read-write stream an SFTP server
15+// talks over.
16+type pipe struct {
17+ io.Reader
18+ io.WriteCloser
19+}
20+
21+// newTestClient returns an SFTP client talking to a real SFTP server — the
22+// one pkg/sftp ships — through in-memory pipes. The server serves this
23+// machine's disk, so the test can check the outcome with package os.
24+func newTestClient(t *testing.T) *sftp.Client {
25+ t.Helper()
26+ toServer, fromClient := io.Pipe()
27+ toClient, fromServer := io.Pipe()
28+
29+ server, err := sftp.NewServer(pipe{toServer, fromServer}, sftp.WithServerWorkingDirectory(t.TempDir()))
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ go server.Serve() //nolint:errcheck // ends when the client closes
34+
35+ client, err := sftp.NewClientPipe(toClient, fromClient)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ // The server goes first: closing its end is what lets the client's reading
40+ // goroutine see the end of the stream, and client.Close waits for it.
41+ t.Cleanup(func() { server.Close(); client.Close() })
42+ return client
43+}
44+
45+// newTestFS returns the remote file system rooted at a fresh directory.
46+func newTestFS(t *testing.T) (*FS, string) {
47+ t.Helper()
48+ root := t.TempDir()
49+ return newFS(newTestClient(t), root), root
50+}
51+
52+func TestReadFileReadsARemoteFileByRelativePath(t *testing.T) {
53+ files, root := newTestFS(t)
54+ if err := os.WriteFile(filepath.Join(root, "main.py"), []byte("print(1)\n"), 0o644); err != nil {
55+ t.Fatal(err)
56+ }
57+
58+ data, err := files.ReadFile("main.py")
59+ if err != nil || string(data) != "print(1)\n" {
60+ t.Errorf("ReadFile = %q, %v", data, err)
61+ }
62+}
63+
64+func TestAMissingRemoteFileIsReportedAsNotExist(t *testing.T) {
65+ files, _ := newTestFS(t)
66+ if _, err := files.ReadFile("nope.py"); !errors.Is(err, fs.ErrNotExist) {
67+ t.Errorf("ReadFile: %v, want fs.ErrNotExist", err)
68+ }
69+ if _, err := files.Stat("nope.py"); !errors.Is(err, fs.ErrNotExist) {
70+ t.Errorf("Stat: %v, want fs.ErrNotExist", err)
71+ }
72+ if _, err := files.ReadDir("nope"); !errors.Is(err, fs.ErrNotExist) {
73+ t.Errorf("ReadDir: %v, want fs.ErrNotExist", err)
74+ }
75+}
76+
77+func TestWriteFileAtomicReplacesTheFileWithTheGivenModeAndLeavesNothingBehind(t *testing.T) {
78+ files, root := newTestFS(t)
79+ target := filepath.Join(root, "run.sh")
80+ if err := os.WriteFile(target, []byte("old"), 0o644); err != nil {
81+ t.Fatal(err)
82+ }
83+
84+ if err := files.WriteFileAtomic("run.sh", []byte("new"), 0o755); err != nil {
85+ t.Fatal(err)
86+ }
87+
88+ data, _ := os.ReadFile(target)
89+ if string(data) != "new" {
90+ t.Errorf("content = %q, want new", data)
91+ }
92+ info, _ := os.Stat(target)
93+ if info.Mode().Perm() != 0o755 {
94+ t.Errorf("mode = %v, want 0755", info.Mode().Perm())
95+ }
96+ entries, _ := os.ReadDir(root)
97+ if len(entries) != 1 {
98+ t.Errorf("%d entries in the directory, want 1: a temporary file was left behind", len(entries))
99+ }
100+}
101+
102+func TestWriteFileAtomicIntoAMissingDirectoryIsNotExist(t *testing.T) {
103+ files, _ := newTestFS(t)
104+ if err := files.WriteFileAtomic("missing/a.py", []byte("x"), 0o644); !errors.Is(err, fs.ErrNotExist) {
105+ t.Errorf("err = %v, want fs.ErrNotExist", err)
106+ }
107+}
108+
109+func TestReadDirListsSortedByName(t *testing.T) {
110+ files, root := newTestFS(t)
111+ for _, name := range []string{"b.py", "a.py", "c.py"} {
112+ if err := os.WriteFile(filepath.Join(root, name), nil, 0o644); err != nil {
113+ t.Fatal(err)
114+ }
115+ }
116+ if err := os.Mkdir(filepath.Join(root, "pkg"), 0o755); err != nil {
117+ t.Fatal(err)
118+ }
119+
120+ entries, err := files.ReadDir(".")
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ var names []string
125+ for _, entry := range entries {
126+ names = append(names, entry.Name())
127+ }
128+ if len(names) != 4 || names[0] != "a.py" || names[3] != "pkg" || !entries[3].IsDir() {
129+ t.Errorf("entries = %v", names)
130+ }
131+}
132+
133+func TestMkdirAllAndStatWorkOnTheRemoteSide(t *testing.T) {
134+ files, root := newTestFS(t)
135+ if err := files.MkdirAll(".turbo-python/deep", 0o755); err != nil {
136+ t.Fatal(err)
137+ }
138+ info, err := files.Stat(".turbo-python/deep")
139+ if err != nil || !info.IsDir() {
140+ t.Errorf("Stat = %v, %v; want a directory", info, err)
141+ }
142+ if _, err := os.Stat(filepath.Join(root, ".turbo-python", "deep")); err != nil {
143+ t.Errorf("the directory is not on disk: %v", err)
144+ }
145+ if working, _ := files.Getwd(); working != root {
146+ t.Errorf("Getwd = %q, want %q", working, root)
147+ }
148+}
149+
150+func TestTheWorkingDirectoryIsResolvedFromTheTargetsPath(t *testing.T) {
151+ client := newTestClient(t)
152+ home, err := client.RealPath(".")
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ if err := os.Mkdir(filepath.Join(home, "app"), 0o755); err != nil {
157+ t.Fatal(err)
158+ }
159+ if err := os.WriteFile(filepath.Join(home, "file.txt"), nil, 0o644); err != nil {
160+ t.Fatal(err)
161+ }
162+
163+ for path, want := range map[string]string{
164+ "": home,
165+ "~": home,
166+ "~/app": filepath.Join(home, "app"),
167+ "app": filepath.Join(home, "app"),
168+ filepath.Join(home, "app"): filepath.Join(home, "app"),
169+ filepath.Join(home, "app/.."): home,
170+ } {
171+ if got, err := workingDirectory(client, path); err != nil || got != want {
172+ t.Errorf("workingDirectory(%q) = %q, %v; want %q", path, got, err, want)
173+ }
174+ }
175+
176+ if _, err := workingDirectory(client, "missing"); err == nil {
177+ t.Error("a missing directory was accepted")
178+ }
179+ if _, err := workingDirectory(client, "file.txt"); !errors.Is(err, fs.ErrInvalid) {
180+ t.Errorf("a file as the directory: %v, want fs.ErrInvalid", err)
181+ }
182+}
added remote/integration_test.go +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+package remote
2+
3+import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+// The real thing, against a real sshd: the demo machine of
13+// turbo-editors/demo-remote-machine, or any machine laid out like it.
14+// Skipped unless TURBO_CORE_SSH_KEY names the private key it accepts:
15+//
16+// cd demo-remote-machine && ./make-key.sh && docker compose up -d --build
17+// TURBO_CORE_SSH_KEY=$PWD/keys/id_demo go test ./remote -run Real -v
18+//
19+// TURBO_CORE_SSH_PORT (default 2222) and TURBO_CORE_SSH_TARGET (default
20+// bob@127.0.0.1:workspace/hello-python) point it elsewhere.
21+func TestRealConnectionToTheDemoMachine(t *testing.T) {
22+ key := os.Getenv("TURBO_CORE_SSH_KEY")
23+ if key == "" {
24+ t.Skip("set TURBO_CORE_SSH_KEY to run against a real sshd (see the comment above)")
25+ }
26+ target, err := ParseTarget(envOr("TURBO_CORE_SSH_TARGET", "bob@127.0.0.1:workspace/hello-python"))
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ program := sshWithConfig(t, key, envOr("TURBO_CORE_SSH_PORT", "2222"))
31+
32+ session, err := Connect(context.Background(), target, Options{Program: program})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer session.Close()
37+
38+ files := session.FS()
39+ working, _ := files.Getwd()
40+ if !strings.HasSuffix(working, "/"+filepath.Base(target.Path)) {
41+ t.Errorf("working directory = %q", working)
42+ }
43+ if err := files.WriteFileAtomic("from_the_test.py", []byte("print('written over sftp')\n"), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+ if data, err := files.ReadFile("from_the_test.py"); err != nil || string(data) != "print('written over sftp')\n" {
47+ t.Errorf("read back %q, %v", data, err)
48+ }
49+
50+ argv := session.RunCommand(working, "python3 from_the_test.py && rm from_the_test.py && pwd", false)
51+ output, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
52+ if err != nil {
53+ t.Fatalf("running a command over the shared connection: %v\n%s", err, output)
54+ }
55+ if got := string(output); got != "written over sftp\n"+working+"\n" {
56+ t.Errorf("the command printed %q", got)
57+ }
58+
59+ controlPath := session.controlPath
60+ if err := session.Close(); err != nil {
61+ t.Errorf("Close: %v", err)
62+ }
63+ check := exec.Command(program, "-S", controlPath, "-O", "check", "--", target.Destination())
64+ if check.Run() == nil {
65+ t.Error("the shared connection is still up after Close")
66+ }
67+}
68+
69+// sshWithConfig writes a wrapper that runs ssh with the port, key and a
70+// throwaway known_hosts of its own, so the test changes nothing in ~/.ssh.
71+// That is the same thing a Host block in ~/.ssh/config does for a person.
72+func sshWithConfig(t *testing.T, key, port string) string {
73+ t.Helper()
74+ directory := t.TempDir()
75+ config := filepath.Join(directory, "config")
76+ contents := "Host *\n" +
77+ " Port " + port + "\n" +
78+ " IdentityFile " + key + "\n" +
79+ " IdentitiesOnly yes\n" +
80+ " UserKnownHostsFile " + filepath.Join(directory, "known_hosts") + "\n" +
81+ " StrictHostKeyChecking accept-new\n"
82+ if err := os.WriteFile(config, []byte(contents), 0o600); err != nil {
83+ t.Fatal(err)
84+ }
85+ wrapper := filepath.Join(directory, "ssh")
86+ script := "#!/bin/sh\nexec ssh -F " + quote(config) + ` "$@"` + "\n"
87+ if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil {
88+ t.Fatal(err)
89+ }
90+ return wrapper
91+}
92+
93+// envOr returns an environment variable, or fallback when it is unset.
94+func envOr(name, fallback string) string {
95+ if value := os.Getenv(name); value != "" {
96+ return value
97+ }
98+ return fallback
99+}
new file mode 100644
@@ -0,0 +1,99 @@
1+package remote
2+
3+import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+// The real thing, against a real sshd: the demo machine of
13+// turbo-editors/demo-remote-machine, or any machine laid out like it.
14+// Skipped unless TURBO_CORE_SSH_KEY names the private key it accepts:
15+//
16+// cd demo-remote-machine && ./make-key.sh && docker compose up -d --build
17+// TURBO_CORE_SSH_KEY=$PWD/keys/id_demo go test ./remote -run Real -v
18+//
19+// TURBO_CORE_SSH_PORT (default 2222) and TURBO_CORE_SSH_TARGET (default
20+// bob@127.0.0.1:workspace/hello-python) point it elsewhere.
21+func TestRealConnectionToTheDemoMachine(t *testing.T) {
22+ key := os.Getenv("TURBO_CORE_SSH_KEY")
23+ if key == "" {
24+ t.Skip("set TURBO_CORE_SSH_KEY to run against a real sshd (see the comment above)")
25+ }
26+ target, err := ParseTarget(envOr("TURBO_CORE_SSH_TARGET", "bob@127.0.0.1:workspace/hello-python"))
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ program := sshWithConfig(t, key, envOr("TURBO_CORE_SSH_PORT", "2222"))
31+
32+ session, err := Connect(context.Background(), target, Options{Program: program})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer session.Close()
37+
38+ files := session.FS()
39+ working, _ := files.Getwd()
40+ if !strings.HasSuffix(working, "/"+filepath.Base(target.Path)) {
41+ t.Errorf("working directory = %q", working)
42+ }
43+ if err := files.WriteFileAtomic("from_the_test.py", []byte("print('written over sftp')\n"), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+ if data, err := files.ReadFile("from_the_test.py"); err != nil || string(data) != "print('written over sftp')\n" {
47+ t.Errorf("read back %q, %v", data, err)
48+ }
49+
50+ argv := session.RunCommand(working, "python3 from_the_test.py && rm from_the_test.py && pwd", false)
51+ output, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
52+ if err != nil {
53+ t.Fatalf("running a command over the shared connection: %v\n%s", err, output)
54+ }
55+ if got := string(output); got != "written over sftp\n"+working+"\n" {
56+ t.Errorf("the command printed %q", got)
57+ }
58+
59+ controlPath := session.controlPath
60+ if err := session.Close(); err != nil {
61+ t.Errorf("Close: %v", err)
62+ }
63+ check := exec.Command(program, "-S", controlPath, "-O", "check", "--", target.Destination())
64+ if check.Run() == nil {
65+ t.Error("the shared connection is still up after Close")
66+ }
67+}
68+
69+// sshWithConfig writes a wrapper that runs ssh with the port, key and a
70+// throwaway known_hosts of its own, so the test changes nothing in ~/.ssh.
71+// That is the same thing a Host block in ~/.ssh/config does for a person.
72+func sshWithConfig(t *testing.T, key, port string) string {
73+ t.Helper()
74+ directory := t.TempDir()
75+ config := filepath.Join(directory, "config")
76+ contents := "Host *\n" +
77+ " Port " + port + "\n" +
78+ " IdentityFile " + key + "\n" +
79+ " IdentitiesOnly yes\n" +
80+ " UserKnownHostsFile " + filepath.Join(directory, "known_hosts") + "\n" +
81+ " StrictHostKeyChecking accept-new\n"
82+ if err := os.WriteFile(config, []byte(contents), 0o600); err != nil {
83+ t.Fatal(err)
84+ }
85+ wrapper := filepath.Join(directory, "ssh")
86+ script := "#!/bin/sh\nexec ssh -F " + quote(config) + ` "$@"` + "\n"
87+ if err := os.WriteFile(wrapper, []byte(script), 0o755); err != nil {
88+ t.Fatal(err)
89+ }
90+ return wrapper
91+}
92+
93+// envOr returns an environment variable, or fallback when it is unset.
94+func envOr(name, fallback string) string {
95+ if value := os.Getenv(name); value != "" {
96+ return value
97+ }
98+ return fallback
99+}
added remote/session.go +359 -0
new file mode 100644
@@ -0,0 +1,359 @@
1+// Package remote lets an editor work on another machine over ssh: its files,
2+// its project tree, its terminals and its tools, as if they were local.
3+//
4+// It drives the ssh command already installed — not an SSH library — so that
5+// everything ssh knows is used as it is: ~/.ssh/config and its Host aliases,
6+// the agent, known_hosts, ProxyJump, a key's passphrase, a password. The first
7+// connection is made in the foreground before the editor takes the screen,
8+// which is where ssh asks for whatever it needs; it becomes a ControlMaster,
9+// and everything afterwards travels through that one authenticated connection
10+// without asking again:
11+//
12+// - the files, over one SFTP channel held open for the whole session, which
13+// is the fsys.FS the editor is told to Use;
14+// - each terminal window, as `ssh -t` running a login shell in the project;
15+// - each tool, as `ssh` running the command line with /bin/sh in the project.
16+//
17+// ControlMaster is an OpenSSH feature that Windows' port does not have, so
18+// Connect returns ErrUnsupported there.
19+//
20+// target, _ := remote.ParseTarget("bob@machine:/srv/app")
21+// session, err := remote.Connect(ctx, target, remote.Options{})
22+// if err != nil {
23+// return err
24+// }
25+// defer session.Close()
26+// fsys.Use(session.FS())
27+package remote
28+
29+import (
30+ "bytes"
31+ "context"
32+ "errors"
33+ "fmt"
34+ "io/fs"
35+ "os"
36+ "os/exec"
37+ "path/filepath"
38+ "runtime"
39+ "strings"
40+ "sync"
41+
42+ "github.com/pkg/sftp"
43+)
44+
45+// ErrUnsupported is returned by Connect on a platform whose ssh cannot share
46+// a connection — Windows.
47+var ErrUnsupported = errors.New("editing over ssh needs OpenSSH's ControlMaster, which this platform's ssh does not have")
48+
49+// ErrNoSSH is returned by Connect when there is no ssh command to run.
50+var ErrNoSSH = errors.New("no ssh command found on PATH")
51+
52+// controlPersist is how long the shared connection outlives its last user.
53+// Every session holds the SFTP channel open until Close, so this only matters
54+// after a crash: the connection then goes away on its own instead of lingering.
55+const controlPersist = "60"
56+
57+// Options change how Connect reaches the machine. The zero value is right for
58+// an editor.
59+type Options struct {
60+ // Program is the ssh command. Empty means "ssh", found on PATH.
61+ Program string
62+}
63+
64+// Session is a live connection to a machine, and the directory being edited
65+// on it.
66+type Session struct {
67+ target Target
68+ program string
69+ controlDir string
70+ controlPath string
71+
72+ sftpProcess *exec.Cmd
73+ sftpErrors *bytes.Buffer
74+ client *sftp.Client
75+ files *FS
76+
77+ closeOnce sync.Once
78+}
79+
80+// Connect logs in to the target, prompting on the terminal if ssh needs to,
81+// then opens the file channel and finds the directory to edit.
82+//
83+// It must be called before the editor takes over the terminal: ssh asks its
84+// questions — a password, a passphrase, whether to trust a new host key —
85+// on the terminal itself.
86+//
87+// The directory must exist: a mistyped path is reported here rather than
88+// becoming an empty project.
89+func Connect(ctx context.Context, target Target, options Options) (*Session, error) {
90+ if runtime.GOOS == "windows" {
91+ return nil, ErrUnsupported
92+ }
93+ program, err := findSSH(options.Program)
94+ if err != nil {
95+ return nil, err
96+ }
97+
98+ s := &Session{target: target, program: program}
99+ if err := s.makeControlDir(); err != nil {
100+ return nil, err
101+ }
102+ if err := s.startMaster(ctx); err != nil {
103+ s.removeControlDir()
104+ return nil, err
105+ }
106+ if err := s.openFiles(ctx); err != nil {
107+ s.Close()
108+ return nil, err
109+ }
110+ return s, nil
111+}
112+
113+// Open is what an editor's -ssh flag does: it reads the argument as a
114+// Target, says on standard error where it is connecting — ssh may be about to
115+// ask for a password, and the question should not come out of nowhere — and
116+// connects with the default Options.
117+//
118+// session, err := remote.Open(ctx, "bob@machine:/srv/app")
119+// if err != nil {
120+// return err
121+// }
122+// defer session.Close()
123+func Open(ctx context.Context, argument string) (*Session, error) {
124+ target, err := ParseTarget(argument)
125+ if err != nil {
126+ return nil, err
127+ }
128+ fmt.Fprintf(os.Stderr, "Connecting to %s…\n", target)
129+ return Connect(ctx, target, Options{})
130+}
131+
132+// findSSH resolves the ssh command.
133+func findSSH(program string) (string, error) {
134+ if program == "" {
135+ program = "ssh"
136+ }
137+ path, err := exec.LookPath(program)
138+ if err != nil {
139+ return "", fmt.Errorf("%w (%v)", ErrNoSSH, err)
140+ }
141+ return path, nil
142+}
143+
144+// makeControlDir makes a private directory for the control socket.
145+//
146+// A Unix socket's path is limited to about a hundred bytes, and macOS's
147+// per-user temporary directory is already most of that, so a short base is
148+// used when the usual one is too long.
149+func (s *Session) makeControlDir() error {
150+ base := os.TempDir()
151+ if len(base) > 60 {
152+ base = "/tmp"
153+ }
154+ dir, err := os.MkdirTemp(base, "turbo-ssh-")
155+ if err != nil {
156+ return fmt.Errorf("making a directory for the ssh control socket: %w", err)
157+ }
158+ s.controlDir = dir
159+ s.controlPath = filepath.Join(dir, "control")
160+ return nil
161+}
162+
163+// removeControlDir takes the control socket's directory away.
164+func (s *Session) removeControlDir() {
165+ if s.controlDir != "" {
166+ _ = os.RemoveAll(s.controlDir)
167+ }
168+}
169+
170+// startMaster makes the first, shared connection, in the foreground.
171+//
172+// -f is what makes ssh return once it has logged in, leaving the connection
173+// running behind the control socket: until then, it owns the terminal and
174+// asks there for whatever it needs.
175+func (s *Session) startMaster(ctx context.Context) error {
176+ command := exec.CommandContext(ctx, s.program, s.masterArgs()...)
177+ command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr
178+ if err := command.Run(); err != nil {
179+ return fmt.Errorf("ssh %s: %w", s.target.Destination(), err)
180+ }
181+ return nil
182+}
183+
184+// masterArgs are the arguments of the first connection.
185+func (s *Session) masterArgs() []string {
186+ return []string{
187+ "-o", "ControlMaster=yes",
188+ "-o", "ControlPath=" + s.controlPath,
189+ "-o", "ControlPersist=" + controlPersist,
190+ "-N", "-f",
191+ "--", s.target.Destination(),
192+ }
193+}
194+
195+// sharedArgs start every later ssh: through the control socket, never making
196+// a connection of its own, never asking anything — the editor owns the
197+// terminal by then, and a prompt would be drawn over it.
198+func (s *Session) sharedArgs() []string {
199+ return []string{
200+ s.program,
201+ "-S", s.controlPath,
202+ "-o", "ControlMaster=no",
203+ "-o", "BatchMode=yes",
204+ }
205+}
206+
207+// openFiles starts the SFTP channel and resolves the working directory.
208+func (s *Session) openFiles(ctx context.Context) error {
209+ args := append(s.sharedArgs(), "-s", "--", s.target.Destination(), "sftp")
210+ process := exec.CommandContext(ctx, args[0], args[1:]...)
211+ s.sftpErrors = &bytes.Buffer{}
212+ process.Stderr = s.sftpErrors
213+
214+ input, err := process.StdinPipe()
215+ if err != nil {
216+ return err
217+ }
218+ output, err := process.StdoutPipe()
219+ if err != nil {
220+ return err
221+ }
222+ if err := process.Start(); err != nil {
223+ return fmt.Errorf("starting the file channel: %w", err)
224+ }
225+ s.sftpProcess = process
226+
227+ client, err := sftp.NewClientPipe(output, input)
228+ if err != nil {
229+ return fmt.Errorf("opening the file channel to %s: %w%s", s.target.Destination(), err, s.channelErrors())
230+ }
231+ s.client = client
232+
233+ working, err := workingDirectory(client, s.target.Path)
234+ if err != nil {
235+ return err
236+ }
237+ s.files = newFS(client, working)
238+ return nil
239+}
240+
241+// channelErrors is what the file channel's ssh said on its way out, if
242+// anything, formatted to follow an error.
243+func (s *Session) channelErrors() string {
244+ said := strings.TrimSpace(s.sftpErrors.String())
245+ if said == "" {
246+ return ""
247+ }
248+ return " (" + said + ")"
249+}
250+
251+// workingDirectory turns the path of the target into an absolute directory
252+// that exists on the machine. "~" and "~/…" mean the home directory, which is
253+// where SFTP resolves a relative path from.
254+func workingDirectory(client *sftp.Client, path string) (string, error) {
255+ switch {
256+ case path == "" || path == "~":
257+ path = "."
258+ case strings.HasPrefix(path, "~/"):
259+ path = strings.TrimPrefix(path, "~/")
260+ }
261+
262+ absolute, err := client.RealPath(path)
263+ if err != nil {
264+ return "", fmt.Errorf("finding %s on the remote machine: %w", path, err)
265+ }
266+ info, err := client.Stat(absolute)
267+ if err != nil {
268+ return "", fmt.Errorf("opening %s on the remote machine: %w", absolute, err)
269+ }
270+ if !info.IsDir() {
271+ return "", fmt.Errorf("%s on the remote machine is not a directory: %w", absolute, fs.ErrInvalid)
272+ }
273+ return absolute, nil
274+}
275+
276+// FS returns the remote files, rooted at the directory being edited — what
277+// the editor passes to fsys.Use.
278+func (s *Session) FS() *FS { return s.files }
279+
280+// Target returns what was connected to.
281+func (s *Session) Target() Target { return s.target }
282+
283+// Name is how the session is shown to the person: user@host.
284+func (s *Session) Name() string { return s.target.Destination() }
285+
286+// ShellCommand returns the command line that opens an interactive login shell
287+// on the machine, in dir, for a terminal window.
288+//
289+// The remote side is handed to /bin/sh explicitly, because ssh gives the
290+// command to the user's own login shell, which may be fish or anything else
291+// that does not read `cd … && exec …` the way sh does.
292+//
293+// argv := session.ShellCommand("/srv/app")
294+// // ["ssh", "-S", …, "-t", "--", "bob@machine", "exec /bin/sh -c 'cd …'"]
295+func (s *Session) ShellCommand(dir string) []string {
296+ script := "cd " + quote(dir) + ` && exec "${SHELL:-/bin/sh}" -l`
297+ return s.remoteCommand(true, script)
298+}
299+
300+// RunCommand returns the command line that runs one command line with
301+// /bin/sh on the machine, in dir — what a tool does.
302+//
303+// withTerminal asks for a pseudo-terminal on the far side: that is what a
304+// terminal window needs, so that a program there can read the keyboard and
305+// Ctrl-C reaches it. A captured run goes without one, so its output is the
306+// command's own and not a terminal's rendering of it.
307+//
308+// argv := session.RunCommand("/srv/app", "python3 -m pytest", false)
309+func (s *Session) RunCommand(dir, command string, withTerminal bool) []string {
310+ return s.remoteCommand(withTerminal, "cd "+quote(dir)+" && "+command)
311+}
312+
313+// remoteCommand wraps a /bin/sh script into an ssh command line.
314+func (s *Session) remoteCommand(withTerminal bool, script string) []string {
315+ terminalFlag := "-T"
316+ if withTerminal {
317+ terminalFlag = "-t"
318+ }
319+ args := append(s.sharedArgs(), terminalFlag, "--", s.target.Destination())
320+ return append(args, "exec /bin/sh -c "+quote(script))
321+}
322+
323+// Close ends the file channel and the shared connection, and removes the
324+// control socket. Terminals and tools still running over the connection end
325+// with it. Closing twice is safe.
326+func (s *Session) Close() error {
327+ var err error
328+ s.closeOnce.Do(func() {
329+ switch {
330+ case s.client != nil:
331+ err = s.client.Close() // closes ssh's input, which ends it
332+ case s.sftpProcess != nil:
333+ // The channel started but never spoke SFTP: nothing will close
334+ // its input, so waiting for it would wait for ever.
335+ _ = s.sftpProcess.Process.Kill()
336+ }
337+ if s.sftpProcess != nil {
338+ _ = s.sftpProcess.Wait()
339+ }
340+ s.stopMaster()
341+ s.removeControlDir()
342+ })
343+ return err
344+}
345+
346+// stopMaster asks the shared connection to end. A master that has already
347+// gone makes this fail, which is the outcome wanted anyway.
348+func (s *Session) stopMaster() {
349+ args := append(s.sharedArgs(), "-O", "exit", "--", s.target.Destination())
350+ stop := exec.Command(args[0], args[1:]...)
351+ _ = stop.Run()
352+}
353+
354+// quote makes s one word for /bin/sh, whatever it holds.
355+//
356+// quote("it's") // 'it'\''s'
357+func quote(s string) string {
358+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
359+}
new file mode 100644
@@ -0,0 +1,359 @@
1+// Package remote lets an editor work on another machine over ssh: its files,
2+// its project tree, its terminals and its tools, as if they were local.
3+//
4+// It drives the ssh command already installed — not an SSH library — so that
5+// everything ssh knows is used as it is: ~/.ssh/config and its Host aliases,
6+// the agent, known_hosts, ProxyJump, a key's passphrase, a password. The first
7+// connection is made in the foreground before the editor takes the screen,
8+// which is where ssh asks for whatever it needs; it becomes a ControlMaster,
9+// and everything afterwards travels through that one authenticated connection
10+// without asking again:
11+//
12+// - the files, over one SFTP channel held open for the whole session, which
13+// is the fsys.FS the editor is told to Use;
14+// - each terminal window, as `ssh -t` running a login shell in the project;
15+// - each tool, as `ssh` running the command line with /bin/sh in the project.
16+//
17+// ControlMaster is an OpenSSH feature that Windows' port does not have, so
18+// Connect returns ErrUnsupported there.
19+//
20+// target, _ := remote.ParseTarget("bob@machine:/srv/app")
21+// session, err := remote.Connect(ctx, target, remote.Options{})
22+// if err != nil {
23+// return err
24+// }
25+// defer session.Close()
26+// fsys.Use(session.FS())
27+package remote
28+
29+import (
30+ "bytes"
31+ "context"
32+ "errors"
33+ "fmt"
34+ "io/fs"
35+ "os"
36+ "os/exec"
37+ "path/filepath"
38+ "runtime"
39+ "strings"
40+ "sync"
41+
42+ "github.com/pkg/sftp"
43+)
44+
45+// ErrUnsupported is returned by Connect on a platform whose ssh cannot share
46+// a connection — Windows.
47+var ErrUnsupported = errors.New("editing over ssh needs OpenSSH's ControlMaster, which this platform's ssh does not have")
48+
49+// ErrNoSSH is returned by Connect when there is no ssh command to run.
50+var ErrNoSSH = errors.New("no ssh command found on PATH")
51+
52+// controlPersist is how long the shared connection outlives its last user.
53+// Every session holds the SFTP channel open until Close, so this only matters
54+// after a crash: the connection then goes away on its own instead of lingering.
55+const controlPersist = "60"
56+
57+// Options change how Connect reaches the machine. The zero value is right for
58+// an editor.
59+type Options struct {
60+ // Program is the ssh command. Empty means "ssh", found on PATH.
61+ Program string
62+}
63+
64+// Session is a live connection to a machine, and the directory being edited
65+// on it.
66+type Session struct {
67+ target Target
68+ program string
69+ controlDir string
70+ controlPath string
71+
72+ sftpProcess *exec.Cmd
73+ sftpErrors *bytes.Buffer
74+ client *sftp.Client
75+ files *FS
76+
77+ closeOnce sync.Once
78+}
79+
80+// Connect logs in to the target, prompting on the terminal if ssh needs to,
81+// then opens the file channel and finds the directory to edit.
82+//
83+// It must be called before the editor takes over the terminal: ssh asks its
84+// questions — a password, a passphrase, whether to trust a new host key —
85+// on the terminal itself.
86+//
87+// The directory must exist: a mistyped path is reported here rather than
88+// becoming an empty project.
89+func Connect(ctx context.Context, target Target, options Options) (*Session, error) {
90+ if runtime.GOOS == "windows" {
91+ return nil, ErrUnsupported
92+ }
93+ program, err := findSSH(options.Program)
94+ if err != nil {
95+ return nil, err
96+ }
97+
98+ s := &Session{target: target, program: program}
99+ if err := s.makeControlDir(); err != nil {
100+ return nil, err
101+ }
102+ if err := s.startMaster(ctx); err != nil {
103+ s.removeControlDir()
104+ return nil, err
105+ }
106+ if err := s.openFiles(ctx); err != nil {
107+ s.Close()
108+ return nil, err
109+ }
110+ return s, nil
111+}
112+
113+// Open is what an editor's -ssh flag does: it reads the argument as a
114+// Target, says on standard error where it is connecting — ssh may be about to
115+// ask for a password, and the question should not come out of nowhere — and
116+// connects with the default Options.
117+//
118+// session, err := remote.Open(ctx, "bob@machine:/srv/app")
119+// if err != nil {
120+// return err
121+// }
122+// defer session.Close()
123+func Open(ctx context.Context, argument string) (*Session, error) {
124+ target, err := ParseTarget(argument)
125+ if err != nil {
126+ return nil, err
127+ }
128+ fmt.Fprintf(os.Stderr, "Connecting to %s…\n", target)
129+ return Connect(ctx, target, Options{})
130+}
131+
132+// findSSH resolves the ssh command.
133+func findSSH(program string) (string, error) {
134+ if program == "" {
135+ program = "ssh"
136+ }
137+ path, err := exec.LookPath(program)
138+ if err != nil {
139+ return "", fmt.Errorf("%w (%v)", ErrNoSSH, err)
140+ }
141+ return path, nil
142+}
143+
144+// makeControlDir makes a private directory for the control socket.
145+//
146+// A Unix socket's path is limited to about a hundred bytes, and macOS's
147+// per-user temporary directory is already most of that, so a short base is
148+// used when the usual one is too long.
149+func (s *Session) makeControlDir() error {
150+ base := os.TempDir()
151+ if len(base) > 60 {
152+ base = "/tmp"
153+ }
154+ dir, err := os.MkdirTemp(base, "turbo-ssh-")
155+ if err != nil {
156+ return fmt.Errorf("making a directory for the ssh control socket: %w", err)
157+ }
158+ s.controlDir = dir
159+ s.controlPath = filepath.Join(dir, "control")
160+ return nil
161+}
162+
163+// removeControlDir takes the control socket's directory away.
164+func (s *Session) removeControlDir() {
165+ if s.controlDir != "" {
166+ _ = os.RemoveAll(s.controlDir)
167+ }
168+}
169+
170+// startMaster makes the first, shared connection, in the foreground.
171+//
172+// -f is what makes ssh return once it has logged in, leaving the connection
173+// running behind the control socket: until then, it owns the terminal and
174+// asks there for whatever it needs.
175+func (s *Session) startMaster(ctx context.Context) error {
176+ command := exec.CommandContext(ctx, s.program, s.masterArgs()...)
177+ command.Stdin, command.Stdout, command.Stderr = os.Stdin, os.Stdout, os.Stderr
178+ if err := command.Run(); err != nil {
179+ return fmt.Errorf("ssh %s: %w", s.target.Destination(), err)
180+ }
181+ return nil
182+}
183+
184+// masterArgs are the arguments of the first connection.
185+func (s *Session) masterArgs() []string {
186+ return []string{
187+ "-o", "ControlMaster=yes",
188+ "-o", "ControlPath=" + s.controlPath,
189+ "-o", "ControlPersist=" + controlPersist,
190+ "-N", "-f",
191+ "--", s.target.Destination(),
192+ }
193+}
194+
195+// sharedArgs start every later ssh: through the control socket, never making
196+// a connection of its own, never asking anything — the editor owns the
197+// terminal by then, and a prompt would be drawn over it.
198+func (s *Session) sharedArgs() []string {
199+ return []string{
200+ s.program,
201+ "-S", s.controlPath,
202+ "-o", "ControlMaster=no",
203+ "-o", "BatchMode=yes",
204+ }
205+}
206+
207+// openFiles starts the SFTP channel and resolves the working directory.
208+func (s *Session) openFiles(ctx context.Context) error {
209+ args := append(s.sharedArgs(), "-s", "--", s.target.Destination(), "sftp")
210+ process := exec.CommandContext(ctx, args[0], args[1:]...)
211+ s.sftpErrors = &bytes.Buffer{}
212+ process.Stderr = s.sftpErrors
213+
214+ input, err := process.StdinPipe()
215+ if err != nil {
216+ return err
217+ }
218+ output, err := process.StdoutPipe()
219+ if err != nil {
220+ return err
221+ }
222+ if err := process.Start(); err != nil {
223+ return fmt.Errorf("starting the file channel: %w", err)
224+ }
225+ s.sftpProcess = process
226+
227+ client, err := sftp.NewClientPipe(output, input)
228+ if err != nil {
229+ return fmt.Errorf("opening the file channel to %s: %w%s", s.target.Destination(), err, s.channelErrors())
230+ }
231+ s.client = client
232+
233+ working, err := workingDirectory(client, s.target.Path)
234+ if err != nil {
235+ return err
236+ }
237+ s.files = newFS(client, working)
238+ return nil
239+}
240+
241+// channelErrors is what the file channel's ssh said on its way out, if
242+// anything, formatted to follow an error.
243+func (s *Session) channelErrors() string {
244+ said := strings.TrimSpace(s.sftpErrors.String())
245+ if said == "" {
246+ return ""
247+ }
248+ return " (" + said + ")"
249+}
250+
251+// workingDirectory turns the path of the target into an absolute directory
252+// that exists on the machine. "~" and "~/…" mean the home directory, which is
253+// where SFTP resolves a relative path from.
254+func workingDirectory(client *sftp.Client, path string) (string, error) {
255+ switch {
256+ case path == "" || path == "~":
257+ path = "."
258+ case strings.HasPrefix(path, "~/"):
259+ path = strings.TrimPrefix(path, "~/")
260+ }
261+
262+ absolute, err := client.RealPath(path)
263+ if err != nil {
264+ return "", fmt.Errorf("finding %s on the remote machine: %w", path, err)
265+ }
266+ info, err := client.Stat(absolute)
267+ if err != nil {
268+ return "", fmt.Errorf("opening %s on the remote machine: %w", absolute, err)
269+ }
270+ if !info.IsDir() {
271+ return "", fmt.Errorf("%s on the remote machine is not a directory: %w", absolute, fs.ErrInvalid)
272+ }
273+ return absolute, nil
274+}
275+
276+// FS returns the remote files, rooted at the directory being edited — what
277+// the editor passes to fsys.Use.
278+func (s *Session) FS() *FS { return s.files }
279+
280+// Target returns what was connected to.
281+func (s *Session) Target() Target { return s.target }
282+
283+// Name is how the session is shown to the person: user@host.
284+func (s *Session) Name() string { return s.target.Destination() }
285+
286+// ShellCommand returns the command line that opens an interactive login shell
287+// on the machine, in dir, for a terminal window.
288+//
289+// The remote side is handed to /bin/sh explicitly, because ssh gives the
290+// command to the user's own login shell, which may be fish or anything else
291+// that does not read `cd … && exec …` the way sh does.
292+//
293+// argv := session.ShellCommand("/srv/app")
294+// // ["ssh", "-S", …, "-t", "--", "bob@machine", "exec /bin/sh -c 'cd …'"]
295+func (s *Session) ShellCommand(dir string) []string {
296+ script := "cd " + quote(dir) + ` && exec "${SHELL:-/bin/sh}" -l`
297+ return s.remoteCommand(true, script)
298+}
299+
300+// RunCommand returns the command line that runs one command line with
301+// /bin/sh on the machine, in dir — what a tool does.
302+//
303+// withTerminal asks for a pseudo-terminal on the far side: that is what a
304+// terminal window needs, so that a program there can read the keyboard and
305+// Ctrl-C reaches it. A captured run goes without one, so its output is the
306+// command's own and not a terminal's rendering of it.
307+//
308+// argv := session.RunCommand("/srv/app", "python3 -m pytest", false)
309+func (s *Session) RunCommand(dir, command string, withTerminal bool) []string {
310+ return s.remoteCommand(withTerminal, "cd "+quote(dir)+" && "+command)
311+}
312+
313+// remoteCommand wraps a /bin/sh script into an ssh command line.
314+func (s *Session) remoteCommand(withTerminal bool, script string) []string {
315+ terminalFlag := "-T"
316+ if withTerminal {
317+ terminalFlag = "-t"
318+ }
319+ args := append(s.sharedArgs(), terminalFlag, "--", s.target.Destination())
320+ return append(args, "exec /bin/sh -c "+quote(script))
321+}
322+
323+// Close ends the file channel and the shared connection, and removes the
324+// control socket. Terminals and tools still running over the connection end
325+// with it. Closing twice is safe.
326+func (s *Session) Close() error {
327+ var err error
328+ s.closeOnce.Do(func() {
329+ switch {
330+ case s.client != nil:
331+ err = s.client.Close() // closes ssh's input, which ends it
332+ case s.sftpProcess != nil:
333+ // The channel started but never spoke SFTP: nothing will close
334+ // its input, so waiting for it would wait for ever.
335+ _ = s.sftpProcess.Process.Kill()
336+ }
337+ if s.sftpProcess != nil {
338+ _ = s.sftpProcess.Wait()
339+ }
340+ s.stopMaster()
341+ s.removeControlDir()
342+ })
343+ return err
344+}
345+
346+// stopMaster asks the shared connection to end. A master that has already
347+// gone makes this fail, which is the outcome wanted anyway.
348+func (s *Session) stopMaster() {
349+ args := append(s.sharedArgs(), "-O", "exit", "--", s.target.Destination())
350+ stop := exec.Command(args[0], args[1:]...)
351+ _ = stop.Run()
352+}
353+
354+// quote makes s one word for /bin/sh, whatever it holds.
355+//
356+// quote("it's") // 'it'\''s'
357+func quote(s string) string {
358+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
359+}
added remote/session_test.go +246 -0
new file mode 100644
@@ -0,0 +1,246 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "slices"
11+ "strings"
12+ "testing"
13+
14+ "github.com/pkg/sftp"
15+)
16+
17+// The environment variables that turn the test binary into a fake ssh.
18+const (
19+ fakeSSHEnv = "TURBO_REMOTE_FAKE_SSH" // set: behave as ssh
20+ fakeHomeEnv = "TURBO_REMOTE_FAKE_HOME" // the "remote" home directory
21+ fakeLogEnv = "TURBO_REMOTE_FAKE_LOG" // where each invocation is recorded
22+ fakeFailEnv = "TURBO_REMOTE_FAKE_FAIL_LOG" // set: the login fails
23+)
24+
25+// TestMain lets the test binary stand in for ssh, so that Connect and Close
26+// run end to end — processes, pipes, a real SFTP conversation — without a
27+// network or a second machine.
28+func TestMain(m *testing.M) {
29+ if os.Getenv(fakeSSHEnv) != "" {
30+ os.Exit(fakeSSH(os.Args[1:]))
31+ }
32+ os.Exit(m.Run())
33+}
34+
35+// fakeSSH does what ssh would for the three invocations Session makes: log in
36+// (-N), open the SFTP subsystem (-s), and stop the master (-O exit).
37+func fakeSSH(args []string) int {
38+ if log := os.Getenv(fakeLogEnv); log != "" {
39+ f, err := os.OpenFile(log, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
40+ if err == nil {
41+ fmt.Fprintln(f, strings.Join(args, " "))
42+ f.Close()
43+ }
44+ }
45+ switch {
46+ case slices.Contains(args, "-N"):
47+ if os.Getenv(fakeFailEnv) != "" {
48+ fmt.Fprintln(os.Stderr, "Permission denied (publickey,password).")
49+ return 255
50+ }
51+ return 0
52+ case slices.Contains(args, "-s") && os.Getenv("TURBO_REMOTE_FAKE_NO_SFTP") != "":
53+ fmt.Fprintln(os.Stderr, "subsystem request failed on channel 0")
54+ return 1
55+ case slices.Contains(args, "-s"):
56+ server, err := sftp.NewServer(pipe{os.Stdin, os.Stdout}, sftp.WithServerWorkingDirectory(os.Getenv(fakeHomeEnv)))
57+ if err != nil {
58+ return 1
59+ }
60+ _ = server.Serve()
61+ return 0
62+ default:
63+ return 0
64+ }
65+}
66+
67+// fakeSession points Connect at the fake ssh, with a home directory holding
68+// an app/ directory, and returns where invocations are logged.
69+func fakeSession(t *testing.T) (home, log string) {
70+ t.Helper()
71+ if runtime.GOOS == "windows" {
72+ t.Skip("Connect is unsupported on Windows")
73+ }
74+ home = t.TempDir()
75+ if err := os.Mkdir(filepath.Join(home, "app"), 0o755); err != nil {
76+ t.Fatal(err)
77+ }
78+ log = filepath.Join(t.TempDir(), "ssh.log")
79+ t.Setenv(fakeSSHEnv, "1")
80+ t.Setenv(fakeHomeEnv, home)
81+ t.Setenv(fakeLogEnv, log)
82+ return home, log
83+}
84+
85+// invocations returns what the fake ssh was asked to do, one line each.
86+func invocations(t *testing.T, log string) []string {
87+ t.Helper()
88+ data, err := os.ReadFile(log)
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ return strings.Split(strings.TrimSpace(string(data)), "\n")
93+}
94+
95+func TestConnectLogsInOpensTheFilesAndCloseStopsTheMaster(t *testing.T) {
96+ home, log := fakeSession(t)
97+
98+ session, err := Connect(context.Background(), Target{User: "bob", Host: "machine", Path: "app"}, Options{Program: os.Args[0]})
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if working, _ := session.FS().Getwd(); working != filepath.Join(home, "app") {
103+ t.Errorf("working directory = %q, want %q", working, filepath.Join(home, "app"))
104+ }
105+ if err := session.FS().WriteFileAtomic("hello.py", []byte("print('hi')\n"), 0o644); err != nil {
106+ t.Fatal(err)
107+ }
108+ if data, _ := os.ReadFile(filepath.Join(home, "app", "hello.py")); string(data) != "print('hi')\n" {
109+ t.Errorf("the file on the far side holds %q", data)
110+ }
111+ controlDir := session.controlDir
112+
113+ if err := session.Close(); err != nil {
114+ t.Errorf("Close: %v", err)
115+ }
116+ if err := session.Close(); err != nil {
117+ t.Errorf("a second Close: %v", err)
118+ }
119+
120+ calls := invocations(t, log)
121+ if len(calls) != 3 {
122+ t.Fatalf("ssh was run %d times, want 3 (log in, files, stop):\n%s", len(calls), strings.Join(calls, "\n"))
123+ }
124+ if !strings.Contains(calls[0], "-N -f -- bob@machine") || !strings.Contains(calls[0], "ControlMaster=yes") {
125+ t.Errorf("the login was %q", calls[0])
126+ }
127+ if !strings.HasSuffix(calls[1], "-s -- bob@machine sftp") || !strings.Contains(calls[1], "BatchMode=yes") {
128+ t.Errorf("the file channel was %q", calls[1])
129+ }
130+ if !strings.Contains(calls[2], "-O exit -- bob@machine") {
131+ t.Errorf("the stop was %q", calls[2])
132+ }
133+ if _, err := os.Stat(controlDir); !errors.Is(err, os.ErrNotExist) {
134+ t.Errorf("the control socket's directory is still there after Close")
135+ }
136+}
137+
138+func TestAFailedLoginIsReportedAndLeavesNothingBehind(t *testing.T) {
139+ fakeSession(t)
140+ t.Setenv(fakeFailEnv, "1")
141+ before, _ := filepath.Glob(filepath.Join(os.TempDir(), "turbo-ssh-*"))
142+
143+ _, err := Connect(context.Background(), Target{User: "bob", Host: "machine"}, Options{Program: os.Args[0]})
144+ if err == nil || !strings.Contains(err.Error(), "ssh bob@machine") {
145+ t.Errorf("err = %v, want one naming the destination", err)
146+ }
147+ after, _ := filepath.Glob(filepath.Join(os.TempDir(), "turbo-ssh-*"))
148+ if len(after) != len(before) {
149+ t.Errorf("a control directory was left behind: %v", after)
150+ }
151+}
152+
153+func TestConnectRefusesADirectoryThatIsNotThere(t *testing.T) {
154+ fakeSession(t)
155+ _, err := Connect(context.Background(), Target{User: "bob", Host: "machine", Path: "nope"}, Options{Program: os.Args[0]})
156+ if err == nil || !strings.Contains(err.Error(), "nope") {
157+ t.Errorf("err = %v, want one naming the missing directory", err)
158+ }
159+}
160+
161+func TestConnectWithoutSSHSaysSo(t *testing.T) {
162+ if runtime.GOOS == "windows" {
163+ t.Skip("Connect is unsupported on Windows")
164+ }
165+ _, err := Connect(context.Background(), Target{Host: "machine"}, Options{Program: "no-such-ssh-anywhere"})
166+ if !errors.Is(err, ErrNoSSH) {
167+ t.Errorf("err = %v, want ErrNoSSH", err)
168+ }
169+}
170+
171+// commandSession is a session with nothing behind it, for checking the
172+// command lines it writes.
173+func commandSession() *Session {
174+ return &Session{target: Target{User: "bob", Host: "machine"}, program: "ssh", controlPath: "/tmp/turbo-ssh-1/control"}
175+}
176+
177+func TestShellCommandOpensALoginShellInTheDirectoryThroughTheSharedConnection(t *testing.T) {
178+ got := commandSession().ShellCommand("/home/bob/my app")
179+ want := []string{
180+ "ssh", "-S", "/tmp/turbo-ssh-1/control", "-o", "ControlMaster=no", "-o", "BatchMode=yes",
181+ "-t", "--", "bob@machine",
182+ `exec /bin/sh -c 'cd '\''/home/bob/my app'\'' && exec "${SHELL:-/bin/sh}" -l'`,
183+ }
184+ if !slices.Equal(got, want) {
185+ t.Errorf("ShellCommand =\n%q\nwant\n%q", got, want)
186+ }
187+}
188+
189+func TestRunCommandRunsTheCommandLineWithShInTheDirectory(t *testing.T) {
190+ s := commandSession()
191+
192+ captured := s.RunCommand("/srv/app", "python3 main.py | tee out", false)
193+ if captured[7] != "-T" {
194+ t.Errorf("a captured run asks for %q, want -T (no terminal)", captured[7])
195+ }
196+ if last := captured[len(captured)-1]; last != `exec /bin/sh -c 'cd '\''/srv/app'\'' && python3 main.py | tee out'` {
197+ t.Errorf("remote command = %s", last)
198+ }
199+
200+ inTerminal := s.RunCommand("/srv/app", "python3", true)
201+ if inTerminal[7] != "-t" {
202+ t.Errorf("a run in a terminal asks for %q, want -t", inTerminal[7])
203+ }
204+}
205+
206+func TestQuoteMakesAnyStringOneShellWord(t *testing.T) {
207+ cases := map[string]string{
208+ "plain": "'plain'",
209+ "with space": "'with space'",
210+ "it's": `'it'\''s'`,
211+ "$HOME;rm": "'$HOME;rm'",
212+ "": "''",
213+ }
214+ for in, want := range cases {
215+ if got := quote(in); got != want {
216+ t.Errorf("quote(%q) = %s, want %s", in, got, want)
217+ }
218+ }
219+}
220+
221+func TestNameIsUserAtHost(t *testing.T) {
222+ if got := commandSession().Name(); got != "bob@machine" {
223+ t.Errorf("Name = %q", got)
224+ }
225+}
226+
227+func TestOpenRefusesABadTargetWithoutRunningSSH(t *testing.T) {
228+ _, log := fakeSession(t)
229+ if _, err := Open(context.Background(), "-oProxyCommand=evil"); !errors.Is(err, ErrBadTarget) {
230+ t.Errorf("err = %v, want ErrBadTarget", err)
231+ }
232+ if _, err := os.Stat(log); !errors.Is(err, os.ErrNotExist) {
233+ t.Error("ssh was run for a target that was refused")
234+ }
235+}
236+
237+func TestAChannelThatNeverSpeaksSFTPIsReportedAndCloseDoesNotHang(t *testing.T) {
238+ fakeSession(t)
239+ // The fake then refuses the subsystem, as a server with no sftp does.
240+ t.Setenv("TURBO_REMOTE_FAKE_NO_SFTP", "1")
241+
242+ _, err := Connect(context.Background(), Target{User: "bob", Host: "machine"}, Options{Program: os.Args[0]})
243+ if err == nil {
244+ t.Fatal("Connect succeeded without a file channel")
245+ }
246+}
new file mode 100644
@@ -0,0 +1,246 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "runtime"
10+ "slices"
11+ "strings"
12+ "testing"
13+
14+ "github.com/pkg/sftp"
15+)
16+
17+// The environment variables that turn the test binary into a fake ssh.
18+const (
19+ fakeSSHEnv = "TURBO_REMOTE_FAKE_SSH" // set: behave as ssh
20+ fakeHomeEnv = "TURBO_REMOTE_FAKE_HOME" // the "remote" home directory
21+ fakeLogEnv = "TURBO_REMOTE_FAKE_LOG" // where each invocation is recorded
22+ fakeFailEnv = "TURBO_REMOTE_FAKE_FAIL_LOG" // set: the login fails
23+)
24+
25+// TestMain lets the test binary stand in for ssh, so that Connect and Close
26+// run end to end — processes, pipes, a real SFTP conversation — without a
27+// network or a second machine.
28+func TestMain(m *testing.M) {
29+ if os.Getenv(fakeSSHEnv) != "" {
30+ os.Exit(fakeSSH(os.Args[1:]))
31+ }
32+ os.Exit(m.Run())
33+}
34+
35+// fakeSSH does what ssh would for the three invocations Session makes: log in
36+// (-N), open the SFTP subsystem (-s), and stop the master (-O exit).
37+func fakeSSH(args []string) int {
38+ if log := os.Getenv(fakeLogEnv); log != "" {
39+ f, err := os.OpenFile(log, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
40+ if err == nil {
41+ fmt.Fprintln(f, strings.Join(args, " "))
42+ f.Close()
43+ }
44+ }
45+ switch {
46+ case slices.Contains(args, "-N"):
47+ if os.Getenv(fakeFailEnv) != "" {
48+ fmt.Fprintln(os.Stderr, "Permission denied (publickey,password).")
49+ return 255
50+ }
51+ return 0
52+ case slices.Contains(args, "-s") && os.Getenv("TURBO_REMOTE_FAKE_NO_SFTP") != "":
53+ fmt.Fprintln(os.Stderr, "subsystem request failed on channel 0")
54+ return 1
55+ case slices.Contains(args, "-s"):
56+ server, err := sftp.NewServer(pipe{os.Stdin, os.Stdout}, sftp.WithServerWorkingDirectory(os.Getenv(fakeHomeEnv)))
57+ if err != nil {
58+ return 1
59+ }
60+ _ = server.Serve()
61+ return 0
62+ default:
63+ return 0
64+ }
65+}
66+
67+// fakeSession points Connect at the fake ssh, with a home directory holding
68+// an app/ directory, and returns where invocations are logged.
69+func fakeSession(t *testing.T) (home, log string) {
70+ t.Helper()
71+ if runtime.GOOS == "windows" {
72+ t.Skip("Connect is unsupported on Windows")
73+ }
74+ home = t.TempDir()
75+ if err := os.Mkdir(filepath.Join(home, "app"), 0o755); err != nil {
76+ t.Fatal(err)
77+ }
78+ log = filepath.Join(t.TempDir(), "ssh.log")
79+ t.Setenv(fakeSSHEnv, "1")
80+ t.Setenv(fakeHomeEnv, home)
81+ t.Setenv(fakeLogEnv, log)
82+ return home, log
83+}
84+
85+// invocations returns what the fake ssh was asked to do, one line each.
86+func invocations(t *testing.T, log string) []string {
87+ t.Helper()
88+ data, err := os.ReadFile(log)
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ return strings.Split(strings.TrimSpace(string(data)), "\n")
93+}
94+
95+func TestConnectLogsInOpensTheFilesAndCloseStopsTheMaster(t *testing.T) {
96+ home, log := fakeSession(t)
97+
98+ session, err := Connect(context.Background(), Target{User: "bob", Host: "machine", Path: "app"}, Options{Program: os.Args[0]})
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ if working, _ := session.FS().Getwd(); working != filepath.Join(home, "app") {
103+ t.Errorf("working directory = %q, want %q", working, filepath.Join(home, "app"))
104+ }
105+ if err := session.FS().WriteFileAtomic("hello.py", []byte("print('hi')\n"), 0o644); err != nil {
106+ t.Fatal(err)
107+ }
108+ if data, _ := os.ReadFile(filepath.Join(home, "app", "hello.py")); string(data) != "print('hi')\n" {
109+ t.Errorf("the file on the far side holds %q", data)
110+ }
111+ controlDir := session.controlDir
112+
113+ if err := session.Close(); err != nil {
114+ t.Errorf("Close: %v", err)
115+ }
116+ if err := session.Close(); err != nil {
117+ t.Errorf("a second Close: %v", err)
118+ }
119+
120+ calls := invocations(t, log)
121+ if len(calls) != 3 {
122+ t.Fatalf("ssh was run %d times, want 3 (log in, files, stop):\n%s", len(calls), strings.Join(calls, "\n"))
123+ }
124+ if !strings.Contains(calls[0], "-N -f -- bob@machine") || !strings.Contains(calls[0], "ControlMaster=yes") {
125+ t.Errorf("the login was %q", calls[0])
126+ }
127+ if !strings.HasSuffix(calls[1], "-s -- bob@machine sftp") || !strings.Contains(calls[1], "BatchMode=yes") {
128+ t.Errorf("the file channel was %q", calls[1])
129+ }
130+ if !strings.Contains(calls[2], "-O exit -- bob@machine") {
131+ t.Errorf("the stop was %q", calls[2])
132+ }
133+ if _, err := os.Stat(controlDir); !errors.Is(err, os.ErrNotExist) {
134+ t.Errorf("the control socket's directory is still there after Close")
135+ }
136+}
137+
138+func TestAFailedLoginIsReportedAndLeavesNothingBehind(t *testing.T) {
139+ fakeSession(t)
140+ t.Setenv(fakeFailEnv, "1")
141+ before, _ := filepath.Glob(filepath.Join(os.TempDir(), "turbo-ssh-*"))
142+
143+ _, err := Connect(context.Background(), Target{User: "bob", Host: "machine"}, Options{Program: os.Args[0]})
144+ if err == nil || !strings.Contains(err.Error(), "ssh bob@machine") {
145+ t.Errorf("err = %v, want one naming the destination", err)
146+ }
147+ after, _ := filepath.Glob(filepath.Join(os.TempDir(), "turbo-ssh-*"))
148+ if len(after) != len(before) {
149+ t.Errorf("a control directory was left behind: %v", after)
150+ }
151+}
152+
153+func TestConnectRefusesADirectoryThatIsNotThere(t *testing.T) {
154+ fakeSession(t)
155+ _, err := Connect(context.Background(), Target{User: "bob", Host: "machine", Path: "nope"}, Options{Program: os.Args[0]})
156+ if err == nil || !strings.Contains(err.Error(), "nope") {
157+ t.Errorf("err = %v, want one naming the missing directory", err)
158+ }
159+}
160+
161+func TestConnectWithoutSSHSaysSo(t *testing.T) {
162+ if runtime.GOOS == "windows" {
163+ t.Skip("Connect is unsupported on Windows")
164+ }
165+ _, err := Connect(context.Background(), Target{Host: "machine"}, Options{Program: "no-such-ssh-anywhere"})
166+ if !errors.Is(err, ErrNoSSH) {
167+ t.Errorf("err = %v, want ErrNoSSH", err)
168+ }
169+}
170+
171+// commandSession is a session with nothing behind it, for checking the
172+// command lines it writes.
173+func commandSession() *Session {
174+ return &Session{target: Target{User: "bob", Host: "machine"}, program: "ssh", controlPath: "/tmp/turbo-ssh-1/control"}
175+}
176+
177+func TestShellCommandOpensALoginShellInTheDirectoryThroughTheSharedConnection(t *testing.T) {
178+ got := commandSession().ShellCommand("/home/bob/my app")
179+ want := []string{
180+ "ssh", "-S", "/tmp/turbo-ssh-1/control", "-o", "ControlMaster=no", "-o", "BatchMode=yes",
181+ "-t", "--", "bob@machine",
182+ `exec /bin/sh -c 'cd '\''/home/bob/my app'\'' && exec "${SHELL:-/bin/sh}" -l'`,
183+ }
184+ if !slices.Equal(got, want) {
185+ t.Errorf("ShellCommand =\n%q\nwant\n%q", got, want)
186+ }
187+}
188+
189+func TestRunCommandRunsTheCommandLineWithShInTheDirectory(t *testing.T) {
190+ s := commandSession()
191+
192+ captured := s.RunCommand("/srv/app", "python3 main.py | tee out", false)
193+ if captured[7] != "-T" {
194+ t.Errorf("a captured run asks for %q, want -T (no terminal)", captured[7])
195+ }
196+ if last := captured[len(captured)-1]; last != `exec /bin/sh -c 'cd '\''/srv/app'\'' && python3 main.py | tee out'` {
197+ t.Errorf("remote command = %s", last)
198+ }
199+
200+ inTerminal := s.RunCommand("/srv/app", "python3", true)
201+ if inTerminal[7] != "-t" {
202+ t.Errorf("a run in a terminal asks for %q, want -t", inTerminal[7])
203+ }
204+}
205+
206+func TestQuoteMakesAnyStringOneShellWord(t *testing.T) {
207+ cases := map[string]string{
208+ "plain": "'plain'",
209+ "with space": "'with space'",
210+ "it's": `'it'\''s'`,
211+ "$HOME;rm": "'$HOME;rm'",
212+ "": "''",
213+ }
214+ for in, want := range cases {
215+ if got := quote(in); got != want {
216+ t.Errorf("quote(%q) = %s, want %s", in, got, want)
217+ }
218+ }
219+}
220+
221+func TestNameIsUserAtHost(t *testing.T) {
222+ if got := commandSession().Name(); got != "bob@machine" {
223+ t.Errorf("Name = %q", got)
224+ }
225+}
226+
227+func TestOpenRefusesABadTargetWithoutRunningSSH(t *testing.T) {
228+ _, log := fakeSession(t)
229+ if _, err := Open(context.Background(), "-oProxyCommand=evil"); !errors.Is(err, ErrBadTarget) {
230+ t.Errorf("err = %v, want ErrBadTarget", err)
231+ }
232+ if _, err := os.Stat(log); !errors.Is(err, os.ErrNotExist) {
233+ t.Error("ssh was run for a target that was refused")
234+ }
235+}
236+
237+func TestAChannelThatNeverSpeaksSFTPIsReportedAndCloseDoesNotHang(t *testing.T) {
238+ fakeSession(t)
239+ // The fake then refuses the subsystem, as a server with no sftp does.
240+ t.Setenv("TURBO_REMOTE_FAKE_NO_SFTP", "1")
241+
242+ _, err := Connect(context.Background(), Target{User: "bob", Host: "machine"}, Options{Program: os.Args[0]})
243+ if err == nil {
244+ t.Fatal("Connect succeeded without a file channel")
245+ }
246+}
added remote/target.go +121 -0
new file mode 100644
@@ -0,0 +1,121 @@
1+package remote
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+)
8+
9+// ErrBadTarget is what ParseTarget returns for something that is not
10+// user@machine[:path].
11+var ErrBadTarget = errors.New("not a remote target; expected user@machine or user@machine:/path")
12+
13+// Target is the machine an editor was asked to work on, and where on it.
14+//
15+// target, err := remote.ParseTarget("bob@build-box:/srv/app")
16+// // target.User == "bob", target.Host == "build-box", target.Path == "/srv/app"
17+type Target struct {
18+ // User is who to log in as. Empty leaves it to ssh: the User line of
19+ // ~/.ssh/config for that host, or the local user name.
20+ User string
21+ // Host is the machine: a name, an address, or a Host alias from
22+ // ~/.ssh/config — ssh resolves it, so every alias there works here.
23+ Host string
24+ // Path is the directory to open. Empty means the remote home directory;
25+ // a relative path is relative to it, as with scp.
26+ Path string
27+}
28+
29+// ParseTarget reads the argument of -ssh, which is written the way scp writes
30+// a remote location: [user@]host[:path]. An IPv6 address goes in brackets:
31+// bob@[2001:db8::1]:/srv.
32+//
33+// A port is not part of the syntax — scp has none either. Put it in
34+// ~/.ssh/config under a Host alias, and give the alias here.
35+//
36+// A host beginning with "-" is refused: ssh would read it as an option, and
37+// an option is the one thing that must never come from a target.
38+//
39+// remote.ParseTarget("bob@machine") // home directory
40+// remote.ParseTarget("bob@machine:~/app") // ~/app
41+// remote.ParseTarget("demo:/srv/app") // a Host alias from ~/.ssh/config
42+func ParseTarget(argument string) (Target, error) {
43+ var target Target
44+
45+ rest := argument
46+ if at := strings.LastIndex(rest, "@"); at >= 0 {
47+ target.User, rest = rest[:at], rest[at+1:]
48+ if target.User == "" {
49+ return Target{}, fmt.Errorf("%q: %w", argument, ErrBadTarget)
50+ }
51+ }
52+
53+ host, path, err := splitHostPath(rest)
54+ if err != nil {
55+ return Target{}, fmt.Errorf("%q: %w", argument, err)
56+ }
57+ target.Host, target.Path = host, path
58+
59+ if err := target.validate(); err != nil {
60+ return Target{}, fmt.Errorf("%q: %w", argument, err)
61+ }
62+ return target, nil
63+}
64+
65+// splitHostPath separates host from path, allowing a bracketed IPv6 host.
66+func splitHostPath(rest string) (host, path string, err error) {
67+ if strings.HasPrefix(rest, "[") {
68+ end := strings.Index(rest, "]")
69+ if end < 0 {
70+ return "", "", ErrBadTarget
71+ }
72+ host, rest = rest[1:end], rest[end+1:]
73+ if rest != "" && !strings.HasPrefix(rest, ":") {
74+ return "", "", ErrBadTarget
75+ }
76+ return host, strings.TrimPrefix(rest, ":"), nil
77+ }
78+
79+ host, path, _ = strings.Cut(rest, ":")
80+ return host, path, nil
81+}
82+
83+// validate refuses what ssh would misread or what cannot be a machine.
84+func (t Target) validate() error {
85+ switch {
86+ case t.Host == "":
87+ return ErrBadTarget
88+ case strings.HasPrefix(t.Host, "-"), strings.HasPrefix(t.User, "-"):
89+ return fmt.Errorf("a host or user may not begin with '-': %w", ErrBadTarget)
90+ case strings.ContainsAny(t.Host+t.User, " \t\r\n/"):
91+ return ErrBadTarget
92+ }
93+ return nil
94+}
95+
96+// Destination is what ssh is given to connect to: user@host, or host alone
97+// when no user was named.
98+//
99+// remote.Target{User: "bob", Host: "machine"}.Destination() // "bob@machine"
100+func (t Target) Destination() string {
101+ if t.User == "" {
102+ return t.Host
103+ }
104+ return t.User + "@" + t.Host
105+}
106+
107+// String writes the target back the way it is typed: user@host[:path].
108+func (t Target) String() string {
109+ host := t.Host
110+ if strings.Contains(host, ":") {
111+ host = "[" + host + "]"
112+ }
113+ written := host
114+ if t.User != "" {
115+ written = t.User + "@" + host
116+ }
117+ if t.Path != "" {
118+ written += ":" + t.Path
119+ }
120+ return written
121+}
new file mode 100644
@@ -0,0 +1,121 @@
1+package remote
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+)
8+
9+// ErrBadTarget is what ParseTarget returns for something that is not
10+// user@machine[:path].
11+var ErrBadTarget = errors.New("not a remote target; expected user@machine or user@machine:/path")
12+
13+// Target is the machine an editor was asked to work on, and where on it.
14+//
15+// target, err := remote.ParseTarget("bob@build-box:/srv/app")
16+// // target.User == "bob", target.Host == "build-box", target.Path == "/srv/app"
17+type Target struct {
18+ // User is who to log in as. Empty leaves it to ssh: the User line of
19+ // ~/.ssh/config for that host, or the local user name.
20+ User string
21+ // Host is the machine: a name, an address, or a Host alias from
22+ // ~/.ssh/config — ssh resolves it, so every alias there works here.
23+ Host string
24+ // Path is the directory to open. Empty means the remote home directory;
25+ // a relative path is relative to it, as with scp.
26+ Path string
27+}
28+
29+// ParseTarget reads the argument of -ssh, which is written the way scp writes
30+// a remote location: [user@]host[:path]. An IPv6 address goes in brackets:
31+// bob@[2001:db8::1]:/srv.
32+//
33+// A port is not part of the syntax — scp has none either. Put it in
34+// ~/.ssh/config under a Host alias, and give the alias here.
35+//
36+// A host beginning with "-" is refused: ssh would read it as an option, and
37+// an option is the one thing that must never come from a target.
38+//
39+// remote.ParseTarget("bob@machine") // home directory
40+// remote.ParseTarget("bob@machine:~/app") // ~/app
41+// remote.ParseTarget("demo:/srv/app") // a Host alias from ~/.ssh/config
42+func ParseTarget(argument string) (Target, error) {
43+ var target Target
44+
45+ rest := argument
46+ if at := strings.LastIndex(rest, "@"); at >= 0 {
47+ target.User, rest = rest[:at], rest[at+1:]
48+ if target.User == "" {
49+ return Target{}, fmt.Errorf("%q: %w", argument, ErrBadTarget)
50+ }
51+ }
52+
53+ host, path, err := splitHostPath(rest)
54+ if err != nil {
55+ return Target{}, fmt.Errorf("%q: %w", argument, err)
56+ }
57+ target.Host, target.Path = host, path
58+
59+ if err := target.validate(); err != nil {
60+ return Target{}, fmt.Errorf("%q: %w", argument, err)
61+ }
62+ return target, nil
63+}
64+
65+// splitHostPath separates host from path, allowing a bracketed IPv6 host.
66+func splitHostPath(rest string) (host, path string, err error) {
67+ if strings.HasPrefix(rest, "[") {
68+ end := strings.Index(rest, "]")
69+ if end < 0 {
70+ return "", "", ErrBadTarget
71+ }
72+ host, rest = rest[1:end], rest[end+1:]
73+ if rest != "" && !strings.HasPrefix(rest, ":") {
74+ return "", "", ErrBadTarget
75+ }
76+ return host, strings.TrimPrefix(rest, ":"), nil
77+ }
78+
79+ host, path, _ = strings.Cut(rest, ":")
80+ return host, path, nil
81+}
82+
83+// validate refuses what ssh would misread or what cannot be a machine.
84+func (t Target) validate() error {
85+ switch {
86+ case t.Host == "":
87+ return ErrBadTarget
88+ case strings.HasPrefix(t.Host, "-"), strings.HasPrefix(t.User, "-"):
89+ return fmt.Errorf("a host or user may not begin with '-': %w", ErrBadTarget)
90+ case strings.ContainsAny(t.Host+t.User, " \t\r\n/"):
91+ return ErrBadTarget
92+ }
93+ return nil
94+}
95+
96+// Destination is what ssh is given to connect to: user@host, or host alone
97+// when no user was named.
98+//
99+// remote.Target{User: "bob", Host: "machine"}.Destination() // "bob@machine"
100+func (t Target) Destination() string {
101+ if t.User == "" {
102+ return t.Host
103+ }
104+ return t.User + "@" + t.Host
105+}
106+
107+// String writes the target back the way it is typed: user@host[:path].
108+func (t Target) String() string {
109+ host := t.Host
110+ if strings.Contains(host, ":") {
111+ host = "[" + host + "]"
112+ }
113+ written := host
114+ if t.User != "" {
115+ written = t.User + "@" + host
116+ }
117+ if t.Path != "" {
118+ written += ":" + t.Path
119+ }
120+ return written
121+}
added remote/target_test.go +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+package remote
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestParseTargetReadsUserHostAndPath(t *testing.T) {
9+ cases := map[string]Target{
10+ "bob@machine": {User: "bob", Host: "machine"},
11+ "bob@machine:/srv/app": {User: "bob", Host: "machine", Path: "/srv/app"},
12+ "bob@machine:app": {User: "bob", Host: "machine", Path: "app"},
13+ "bob@machine:~/app": {User: "bob", Host: "machine", Path: "~/app"},
14+ "demo": {Host: "demo"},
15+ "demo:/srv": {Host: "demo", Path: "/srv"},
16+ "bob@192.168.1.10:/home": {User: "bob", Host: "192.168.1.10", Path: "/home"},
17+ "bob@[::1]:/srv": {User: "bob", Host: "::1", Path: "/srv"},
18+ "bob@[2001:db8::1]": {User: "bob", Host: "2001:db8::1"},
19+ "a@b@machine": {User: "a@b", Host: "machine"},
20+ }
21+ for argument, want := range cases {
22+ got, err := ParseTarget(argument)
23+ if err != nil || got != want {
24+ t.Errorf("ParseTarget(%q) = %+v, %v; want %+v", argument, got, err, want)
25+ }
26+ }
27+}
28+
29+func TestParseTargetRefusesWhatIsNotAMachine(t *testing.T) {
30+ for _, argument := range []string{
31+ "",
32+ "bob@",
33+ "@machine",
34+ ":/srv",
35+ "-oProxyCommand=evil",
36+ "bob@-oProxyCommand=evil",
37+ "-bob@machine",
38+ "bob@ma chine",
39+ "bob@[::1",
40+ "bob@[::1]x",
41+ "bob@host/name",
42+ } {
43+ if _, err := ParseTarget(argument); !errors.Is(err, ErrBadTarget) {
44+ t.Errorf("ParseTarget(%q) error = %v, want ErrBadTarget", argument, err)
45+ }
46+ }
47+}
48+
49+func TestDestinationIsWhatSSHConnectsTo(t *testing.T) {
50+ if got := (Target{User: "bob", Host: "machine", Path: "/x"}).Destination(); got != "bob@machine" {
51+ t.Errorf("Destination = %q", got)
52+ }
53+ if got := (Target{Host: "demo"}).Destination(); got != "demo" {
54+ t.Errorf("Destination without a user = %q", got)
55+ }
56+}
57+
58+func TestStringWritesTheTargetBackAsItIsTyped(t *testing.T) {
59+ for _, typed := range []string{"bob@machine", "bob@machine:/srv", "demo", "bob@[::1]:/srv"} {
60+ target, err := ParseTarget(typed)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if target.String() != typed {
65+ t.Errorf("String() = %q, want %q", target.String(), typed)
66+ }
67+ }
68+}
new file mode 100644
@@ -0,0 +1,68 @@
1+package remote
2+
3+import (
4+ "errors"
5+ "testing"
6+)
7+
8+func TestParseTargetReadsUserHostAndPath(t *testing.T) {
9+ cases := map[string]Target{
10+ "bob@machine": {User: "bob", Host: "machine"},
11+ "bob@machine:/srv/app": {User: "bob", Host: "machine", Path: "/srv/app"},
12+ "bob@machine:app": {User: "bob", Host: "machine", Path: "app"},
13+ "bob@machine:~/app": {User: "bob", Host: "machine", Path: "~/app"},
14+ "demo": {Host: "demo"},
15+ "demo:/srv": {Host: "demo", Path: "/srv"},
16+ "bob@192.168.1.10:/home": {User: "bob", Host: "192.168.1.10", Path: "/home"},
17+ "bob@[::1]:/srv": {User: "bob", Host: "::1", Path: "/srv"},
18+ "bob@[2001:db8::1]": {User: "bob", Host: "2001:db8::1"},
19+ "a@b@machine": {User: "a@b", Host: "machine"},
20+ }
21+ for argument, want := range cases {
22+ got, err := ParseTarget(argument)
23+ if err != nil || got != want {
24+ t.Errorf("ParseTarget(%q) = %+v, %v; want %+v", argument, got, err, want)
25+ }
26+ }
27+}
28+
29+func TestParseTargetRefusesWhatIsNotAMachine(t *testing.T) {
30+ for _, argument := range []string{
31+ "",
32+ "bob@",
33+ "@machine",
34+ ":/srv",
35+ "-oProxyCommand=evil",
36+ "bob@-oProxyCommand=evil",
37+ "-bob@machine",
38+ "bob@ma chine",
39+ "bob@[::1",
40+ "bob@[::1]x",
41+ "bob@host/name",
42+ } {
43+ if _, err := ParseTarget(argument); !errors.Is(err, ErrBadTarget) {
44+ t.Errorf("ParseTarget(%q) error = %v, want ErrBadTarget", argument, err)
45+ }
46+ }
47+}
48+
49+func TestDestinationIsWhatSSHConnectsTo(t *testing.T) {
50+ if got := (Target{User: "bob", Host: "machine", Path: "/x"}).Destination(); got != "bob@machine" {
51+ t.Errorf("Destination = %q", got)
52+ }
53+ if got := (Target{Host: "demo"}).Destination(); got != "demo" {
54+ t.Errorf("Destination without a user = %q", got)
55+ }
56+}
57+
58+func TestStringWritesTheTargetBackAsItIsTyped(t *testing.T) {
59+ for _, typed := range []string{"bob@machine", "bob@machine:/srv", "demo", "bob@[::1]:/srv"} {
60+ target, err := ParseTarget(typed)
61+ if err != nil {
62+ t.Fatal(err)
63+ }
64+ if target.String() != typed {
65+ t.Errorf("String() = %q, want %q", target.String(), typed)
66+ }
67+ }
68+}
modified settings/README.md +1 -1
@@ -2,7 +2,7 @@
22
33 Reads, creates and updates a project's own editor settings: `settings.toml` inside the editor's own directory — `.turbo-go` for Turbo Go, `.turbo-rust` for Turbo Rust. Which directory that is comes from the `profile.Profile` every function here takes.
44
5-Imports the standard library, the TOML parser and `profile`, and nothing else — no tcell, no `ui`, no `app`. It is tested by writing files and reading them back, with no screen and no event loop.
5+Imports the standard library, the TOML parser, `profile` and `fsys` — through which the file is read and written, so under `-ssh` it is the remote project's — and nothing else — no tcell, no `ui`, no `app`. It is tested by writing files and reading them back, with no screen and no event loop.
66
77 ## Where it looks
88
@@ -2,7 +2,7 @@
2 2
3 Reads, creates and updates a project's own editor settings: `settings.toml` inside the editor's own directory — `.turbo-go` for Turbo Go, `.turbo-rust` for Turbo Rust. Which directory that is comes from the `profile.Profile` every function here takes.3 Reads, creates and updates a project's own editor settings: `settings.toml` inside the editor's own directory — `.turbo-go` for Turbo Go, `.turbo-rust` for Turbo Rust. Which directory that is comes from the `profile.Profile` every function here takes.
4 4
5-Imports the standard library, the TOML parser and `profile`, and nothing else — no tcell, no `ui`, no `app`. It is tested by writing files and reading them back, with no screen and no event loop.5+Imports the standard library, the TOML parser, `profile` and `fsys` — through which the file is read and written, so under `-ssh` it is the remote project's — and nothing else — no tcell, no `ui`, no `app`. It is tested by writing files and reading them back, with no screen and no event loop.
6 6
7 ## Where it looks7 ## Where it looks
8 8
modified settings/create.go +2 -2
@@ -2,8 +2,8 @@ package settings
22
33 import (
44 "fmt"
5- "os"
65
6+ "rickub.com/turbo-editors/turbo-core/fsys"
77 "rickub.com/turbo-editors/turbo-core/profile"
88 "rickub.com/turbo-editors/turbo-core/projectfile"
99 )
@@ -49,7 +49,7 @@ func Create(p profile.Profile, projectDir, themeName string) (string, error) {
4949 //
5050 // settings.SetTheme(settings.Path("."), "turbo-dark")
5151 func SetTheme(path, themeName string) error {
52- data, err := os.ReadFile(path)
52+ data, err := fsys.ReadFile(path)
5353 if err != nil {
5454 return fmt.Errorf("reading %s: %w", path, err)
5555 }
@@ -2,8 +2,8 @@ package settings
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "os"
6 5
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7 "rickub.com/turbo-editors/turbo-core/profile"7 "rickub.com/turbo-editors/turbo-core/profile"
8 "rickub.com/turbo-editors/turbo-core/projectfile"8 "rickub.com/turbo-editors/turbo-core/projectfile"
9 )9 )
@@ -49,7 +49,7 @@ func Create(p profile.Profile, projectDir, themeName string) (string, error) {
49 //49 //
50 // settings.SetTheme(settings.Path("."), "turbo-dark")50 // settings.SetTheme(settings.Path("."), "turbo-dark")
51 func SetTheme(path, themeName string) error {51 func SetTheme(path, themeName string) error {
52- data, err := os.ReadFile(path)52+ data, err := fsys.ReadFile(path)
53 if err != nil {53 if err != nil {
54 return fmt.Errorf("reading %s: %w", path, err)54 return fmt.Errorf("reading %s: %w", path, err)
55 }55 }
added settings/fsys_test.go +31 -0
new file mode 100644
@@ -0,0 +1,31 @@
1+package settings
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestSettingsAreReadAndWrittenThroughTheCurrentFileSystem(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+ memory.AddFile("/remote/project/.turbo-test/settings.toml", "[editor]\ntheme = \"turbo-dark\"\n")
15+
16+ p := testProfile()
17+ if !Exists(p, "/remote/project") {
18+ t.Fatal("Exists = false for a settings file that is only in the current file system")
19+ }
20+ loaded, err := Load(p, "/remote/project")
21+ if err != nil || loaded.Theme != "turbo-dark" {
22+ t.Fatalf("Load = %+v, %v", loaded, err)
23+ }
24+
25+ if err := SetTheme(Path(p, "/remote/project"), "turbo-classic"); err != nil {
26+ t.Fatal(err)
27+ }
28+ if got, _ := memory.File("/remote/project/.turbo-test/settings.toml"); got != "[editor]\ntheme = \"turbo-classic\"\n" {
29+ t.Errorf("after SetTheme the file holds %q", got)
30+ }
31+}
new file mode 100644
@@ -0,0 +1,31 @@
1+package settings
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestSettingsAreReadAndWrittenThroughTheCurrentFileSystem(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+ memory.AddFile("/remote/project/.turbo-test/settings.toml", "[editor]\ntheme = \"turbo-dark\"\n")
15+
16+ p := testProfile()
17+ if !Exists(p, "/remote/project") {
18+ t.Fatal("Exists = false for a settings file that is only in the current file system")
19+ }
20+ loaded, err := Load(p, "/remote/project")
21+ if err != nil || loaded.Theme != "turbo-dark" {
22+ t.Fatalf("Load = %+v, %v", loaded, err)
23+ }
24+
25+ if err := SetTheme(Path(p, "/remote/project"), "turbo-classic"); err != nil {
26+ t.Fatal(err)
27+ }
28+ if got, _ := memory.File("/remote/project/.turbo-test/settings.toml"); got != "[editor]\ntheme = \"turbo-classic\"\n" {
29+ t.Errorf("after SetTheme the file holds %q", got)
30+ }
31+}
modified settings/settings.go +5 -4
@@ -18,12 +18,13 @@ package settings
1818 import (
1919 "errors"
2020 "fmt"
21- "os"
21+ "io/fs"
2222 "path/filepath"
2323 "time"
2424
2525 "github.com/BurntSushi/toml"
2626
27+ "rickub.com/turbo-editors/turbo-core/fsys"
2728 "rickub.com/turbo-editors/turbo-core/profile"
2829 )
2930
@@ -88,7 +89,7 @@ func Path(p profile.Profile, projectDir string) string {
8889 // A directory in its place counts as absent: it is not something Load could
8990 // have read, and saying otherwise would only move the error later.
9091 func Exists(p profile.Profile, projectDir string) bool {
91- info, err := os.Stat(Path(p, projectDir))
92+ info, err := fsys.Stat(Path(p, projectDir))
9293 return err == nil && info.Mode().IsRegular()
9394 }
9495
@@ -108,9 +109,9 @@ func Exists(p profile.Profile, projectDir string) bool {
108109 func Load(p profile.Profile, projectDir string) (Settings, error) {
109110 path := Path(p, projectDir)
110111
111- data, err := os.ReadFile(path)
112+ data, err := fsys.ReadFile(path)
112113 if err != nil {
113- if errors.Is(err, os.ErrNotExist) {
114+ if errors.Is(err, fs.ErrNotExist) {
114115 return Settings{}, fmt.Errorf("%w: %s", ErrNotFound, path)
115116 }
116117 return Settings{}, fmt.Errorf("reading %s: %w", path, err)
@@ -18,12 +18,13 @@ package settings
18 import (18 import (
19 "errors"19 "errors"
20 "fmt"20 "fmt"
21- "os"21+ "io/fs"
22 "path/filepath"22 "path/filepath"
23 "time"23 "time"
24 24
25 "github.com/BurntSushi/toml"25 "github.com/BurntSushi/toml"
26 26
27+ "rickub.com/turbo-editors/turbo-core/fsys"
27 "rickub.com/turbo-editors/turbo-core/profile"28 "rickub.com/turbo-editors/turbo-core/profile"
28 )29 )
29 30
@@ -88,7 +89,7 @@ func Path(p profile.Profile, projectDir string) string {
88 // A directory in its place counts as absent: it is not something Load could89 // A directory in its place counts as absent: it is not something Load could
89 // have read, and saying otherwise would only move the error later.90 // have read, and saying otherwise would only move the error later.
90 func Exists(p profile.Profile, projectDir string) bool {91 func Exists(p profile.Profile, projectDir string) bool {
91- info, err := os.Stat(Path(p, projectDir))92+ info, err := fsys.Stat(Path(p, projectDir))
92 return err == nil && info.Mode().IsRegular()93 return err == nil && info.Mode().IsRegular()
93 }94 }
94 95
@@ -108,9 +109,9 @@ func Exists(p profile.Profile, projectDir string) bool {
108 func Load(p profile.Profile, projectDir string) (Settings, error) {109 func Load(p profile.Profile, projectDir string) (Settings, error) {
109 path := Path(p, projectDir)110 path := Path(p, projectDir)
110 111
111- data, err := os.ReadFile(path)112+ data, err := fsys.ReadFile(path)
112 if err != nil {113 if err != nil {
113- if errors.Is(err, os.ErrNotExist) {114+ if errors.Is(err, fs.ErrNotExist) {
114 return Settings{}, fmt.Errorf("%w: %s", ErrNotFound, path)115 return Settings{}, fmt.Errorf("%w: %s", ErrNotFound, path)
115 }116 }
116 return Settings{}, fmt.Errorf("reading %s: %w", path, err)117 return Settings{}, fmt.Errorf("reading %s: %w", path, err)
modified snippets/README.md +1 -1
@@ -2,7 +2,7 @@
22
33 Reads the reusable pieces of text a project and a user keep in TOML, and groups them for a menu to show.
44
5-Imports the standard library and the TOML parser, and nothing else — no tcell, no `ui`, no `app`. It is tested by writing files and reading them back.
5+Imports the standard library, the TOML parser and `fsys`, and nothing else — no tcell, no `ui`, no `app`. The project's file is read through `fsys`, so under `-ssh` it is the remote project's; the user's own file is read with `os`, on this machine, because habits follow the person. It is tested by writing files and reading them back.
66
77 ## Two files, and why the project wins
88
@@ -2,7 +2,7 @@
2 2
3 Reads the reusable pieces of text a project and a user keep in TOML, and groups them for a menu to show.3 Reads the reusable pieces of text a project and a user keep in TOML, and groups them for a menu to show.
4 4
5-Imports the standard library and the TOML parser, and nothing else — no tcell, no `ui`, no `app`. It is tested by writing files and reading them back.5+Imports the standard library, the TOML parser and `fsys`, and nothing else — no tcell, no `ui`, no `app`. The project's file is read through `fsys`, so under `-ssh` it is the remote project's; the user's own file is read with `os`, on this machine, because habits follow the person. It is tested by writing files and reading them back.
6 6
7 ## Two files, and why the project wins7 ## Two files, and why the project wins
8 8
modified snippets/create.go +2 -2
@@ -2,8 +2,8 @@ package snippets
22
33 import (
44 "fmt"
5- "os"
65
6+ "rickub.com/turbo-editors/turbo-core/fsys"
77 "rickub.com/turbo-editors/turbo-core/profile"
88 "rickub.com/turbo-editors/turbo-core/projectfile"
99 )
@@ -53,6 +53,6 @@ func Exists(p profile.Profile, projectDir string) bool {
5353 // exists reports whether a path is a regular file. A directory in its place
5454 // counts as absent: it is not something Load could have read.
5555 func exists(path string) bool {
56- info, err := os.Stat(path)
56+ info, err := fsys.Stat(path)
5757 return err == nil && info.Mode().IsRegular()
5858 }
@@ -2,8 +2,8 @@ package snippets
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "os"
6 5
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7 "rickub.com/turbo-editors/turbo-core/profile"7 "rickub.com/turbo-editors/turbo-core/profile"
8 "rickub.com/turbo-editors/turbo-core/projectfile"8 "rickub.com/turbo-editors/turbo-core/projectfile"
9 )9 )
@@ -53,6 +53,6 @@ func Exists(p profile.Profile, projectDir string) bool {
53 // exists reports whether a path is a regular file. A directory in its place53 // exists reports whether a path is a regular file. A directory in its place
54 // counts as absent: it is not something Load could have read.54 // counts as absent: it is not something Load could have read.
55 func exists(path string) bool {55 func exists(path string) bool {
56- info, err := os.Stat(path)56+ info, err := fsys.Stat(path)
57 return err == nil && info.Mode().IsRegular()57 return err == nil && info.Mode().IsRegular()
58 }58 }
added snippets/fsys_test.go +37 -0
new file mode 100644
@@ -0,0 +1,37 @@
1+package snippets
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "rickub.com/turbo-editors/turbo-core/fsys"
9+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
10+)
11+
12+// The project's snippets live with the project, wherever that is; the user's
13+// live with the user, on this machine. Under -ssh they come from two places.
14+func TestTheProjectsSnippetsComeFromTheCurrentFileSystemAndTheUsersFromThisMachine(t *testing.T) {
15+ p := testProfile()
16+ userDir := t.TempDir()
17+ t.Setenv(p.SnippetDirEnvVar(), userDir)
18+ if err := os.WriteFile(filepath.Join(userDir, FileName), []byte("[[snippet]]\nname = \"mine\"\nbody = \"m\"\n"), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+
22+ memory := fsystest.New("/remote/project")
23+ previous := fsys.Use(memory)
24+ t.Cleanup(func() { fsys.Use(previous) })
25+ memory.AddFile(ProjectPath(p, "/remote/project"), "[[snippet]]\nname = \"theirs\"\nbody = \"t\"\n")
26+
27+ list, err := Load(p, "/remote/project")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if list.Len() != 2 {
32+ t.Errorf("Len() = %d, want the user's snippet and the project's", list.Len())
33+ }
34+ if !Exists(p, "/remote/project") {
35+ t.Error("Exists = false for a snippets file that is only in the current file system")
36+ }
37+}
new file mode 100644
@@ -0,0 +1,37 @@
1+package snippets
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "rickub.com/turbo-editors/turbo-core/fsys"
9+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
10+)
11+
12+// The project's snippets live with the project, wherever that is; the user's
13+// live with the user, on this machine. Under -ssh they come from two places.
14+func TestTheProjectsSnippetsComeFromTheCurrentFileSystemAndTheUsersFromThisMachine(t *testing.T) {
15+ p := testProfile()
16+ userDir := t.TempDir()
17+ t.Setenv(p.SnippetDirEnvVar(), userDir)
18+ if err := os.WriteFile(filepath.Join(userDir, FileName), []byte("[[snippet]]\nname = \"mine\"\nbody = \"m\"\n"), 0o644); err != nil {
19+ t.Fatal(err)
20+ }
21+
22+ memory := fsystest.New("/remote/project")
23+ previous := fsys.Use(memory)
24+ t.Cleanup(func() { fsys.Use(previous) })
25+ memory.AddFile(ProjectPath(p, "/remote/project"), "[[snippet]]\nname = \"theirs\"\nbody = \"t\"\n")
26+
27+ list, err := Load(p, "/remote/project")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if list.Len() != 2 {
32+ t.Errorf("Len() = %d, want the user's snippet and the project's", list.Len())
33+ }
34+ if !Exists(p, "/remote/project") {
35+ t.Error("Exists = false for a snippets file that is only in the current file system")
36+ }
37+}
modified snippets/snippets.go +19 -7
@@ -16,11 +16,13 @@ package snippets
1616 import (
1717 "errors"
1818 "fmt"
19+ "io/fs"
1920 "os"
2021 "path/filepath"
2122
2223 "github.com/BurntSushi/toml"
2324
25+ "rickub.com/turbo-editors/turbo-core/fsys"
2426 "rickub.com/turbo-editors/turbo-core/profile"
2527 )
2628
@@ -155,11 +157,21 @@ func UserPath(p profile.Profile) string {
155157 func Load(p profile.Profile, projectDir string) (List, error) {
156158 var list List
157159
158- for _, path := range []string{UserPath(p), ProjectPath(p, projectDir)} {
159- if path == "" {
160+ // The user's file is on this machine, wherever the project is: habits
161+ // follow the person. The project's is read through fsys, so under -ssh it
162+ // is the remote project's.
163+ sources := []struct {
164+ path string
165+ read func(string) ([]byte, error)
166+ }{
167+ {UserPath(p), os.ReadFile},
168+ {ProjectPath(p, projectDir), fsys.ReadFile},
169+ }
170+ for _, source := range sources {
171+ if source.path == "" {
160172 continue
161173 }
162- read, err := loadFile(path)
174+ read, err := loadFile(source.path, source.read)
163175 if err != nil {
164176 return List{}, err
165177 }
@@ -198,11 +210,11 @@ type file struct {
198210 Snippet []Snippet `toml:"snippet"`
199211 }
200212
201-// loadFile reads one snippets file, treating a missing one as empty.
202-func loadFile(path string) ([]Snippet, error) {
203- data, err := os.ReadFile(path)
213+// loadFile reads one snippets file with read, treating a missing one as empty.
214+func loadFile(path string, read func(string) ([]byte, error)) ([]Snippet, error) {
215+ data, err := read(path)
204216 if err != nil {
205- if errors.Is(err, os.ErrNotExist) {
217+ if errors.Is(err, fs.ErrNotExist) {
206218 return nil, nil
207219 }
208220 return nil, fmt.Errorf("reading %s: %w", path, err)
@@ -16,11 +16,13 @@ package snippets
16 import (16 import (
17 "errors"17 "errors"
18 "fmt"18 "fmt"
19+ "io/fs"
19 "os"20 "os"
20 "path/filepath"21 "path/filepath"
21 22
22 "github.com/BurntSushi/toml"23 "github.com/BurntSushi/toml"
23 24
25+ "rickub.com/turbo-editors/turbo-core/fsys"
24 "rickub.com/turbo-editors/turbo-core/profile"26 "rickub.com/turbo-editors/turbo-core/profile"
25 )27 )
26 28
@@ -155,11 +157,21 @@ func UserPath(p profile.Profile) string {
155 func Load(p profile.Profile, projectDir string) (List, error) {157 func Load(p profile.Profile, projectDir string) (List, error) {
156 var list List158 var list List
157 159
158- for _, path := range []string{UserPath(p), ProjectPath(p, projectDir)} {160+ // The user's file is on this machine, wherever the project is: habits
159- if path == "" {161+ // follow the person. The project's is read through fsys, so under -ssh it
162+ // is the remote project's.
163+ sources := []struct {
164+ path string
165+ read func(string) ([]byte, error)
166+ }{
167+ {UserPath(p), os.ReadFile},
168+ {ProjectPath(p, projectDir), fsys.ReadFile},
169+ }
170+ for _, source := range sources {
171+ if source.path == "" {
160 continue172 continue
161 }173 }
162- read, err := loadFile(path)174+ read, err := loadFile(source.path, source.read)
163 if err != nil {175 if err != nil {
164 return List{}, err176 return List{}, err
165 }177 }
@@ -198,11 +210,11 @@ type file struct {
198 Snippet []Snippet `toml:"snippet"`210 Snippet []Snippet `toml:"snippet"`
199 }211 }
200 212
201-// loadFile reads one snippets file, treating a missing one as empty.213+// loadFile reads one snippets file with read, treating a missing one as empty.
202-func loadFile(path string) ([]Snippet, error) {214+func loadFile(path string, read func(string) ([]byte, error)) ([]Snippet, error) {
203- data, err := os.ReadFile(path)215+ data, err := read(path)
204 if err != nil {216 if err != nil {
205- if errors.Is(err, os.ErrNotExist) {217+ if errors.Is(err, fs.ErrNotExist) {
206 return nil, nil218 return nil, nil
207 }219 }
208 return nil, fmt.Errorf("reading %s: %w", path, err)220 return nil, fmt.Errorf("reading %s: %w", path, err)
modified tools/README.md +3 -1
@@ -2,7 +2,7 @@
22
33 Reads the commands a project runs on itself — its formatter, its linter, its build, its tests — from `tools.toml` inside the editor's own directory — `.turbo-go` for Turbo Go, `.turbo-rust` for Turbo Rust — and runs one.
44
5-Imports the standard library, the TOML parser and `projectfile`, and nothing else: no tcell, no `ui`. The file half is tested by writing files and reading them back; the running half by running real commands and comparing what came out.
5+Imports the standard library, the TOML parser, `projectfile` and `fsys` (the tools file is read through it, so under `-ssh` it is the remote project's), and nothing else: no tcell, no `ui`. The file half is tested by writing files and reading them back; the running half by running real commands and comparing what came out.
66
77 ## One file, and no user-level one
88
@@ -45,6 +45,8 @@ Two things about it are deliberate:
4545 - **`onLine` is a parameter of `Start`, not a field.** `Start` begins the goroutine that calls it, so a field assigned afterwards would be a data race — the exact one `terminal.ViewOptions` was created to fix, and not one to reintroduce in a new package.
4646 - **Output is capped at 10000 lines**, oldest first, with `Dropped()` reporting how many went. A runaway command would otherwise grow a dialog until it grew the editor; the tail of a failing build is the part that matters, so the head is what goes, and the caller is told rather than quietly shown less.
4747
48+**`StartProgram(command, argv, onLine)`** is the same without the local shell: it runs `argv` as given, under the name `command`. It exists for a tool run on another machine — `app` passes it `session.RunCommand(dir, command, false)`, which is `ssh … /bin/sh -c …` — so the dialog is titled after the command line in the tools file, not after ssh.
49+
4850 `output = "terminal"` does not come through here at all: `app` hands that to `terminal` instead, using `Shell()` and `ShellArgs()` from this package so both spell the shell the same way.
4951
5052 **Stopping a command stops what it started.** On Unix the command gets a process group of its own (`Setpgid`) and `Stop` kills the group; on Windows it is assigned to a **job object** created with `KILL_ON_JOB_CLOSE`, and `Stop` terminates the job. The Windows assignment happens after the process has started, so a child spawned in those first milliseconds escapes — `exec.Cmd` has no way to start a process suspended. Both live behind the `group` interface in `run.go`; `shell_unix.go`, `shell_windows.go` and `group_other.go` are the three implementations.
@@ -2,7 +2,7 @@
2 2
3 Reads the commands a project runs on itself — its formatter, its linter, its build, its tests — from `tools.toml` inside the editor's own directory — `.turbo-go` for Turbo Go, `.turbo-rust` for Turbo Rust — and runs one.3 Reads the commands a project runs on itself — its formatter, its linter, its build, its tests — from `tools.toml` inside the editor's own directory — `.turbo-go` for Turbo Go, `.turbo-rust` for Turbo Rust — and runs one.
4 4
5-Imports the standard library, the TOML parser and `projectfile`, and nothing else: no tcell, no `ui`. The file half is tested by writing files and reading them back; the running half by running real commands and comparing what came out.5+Imports the standard library, the TOML parser, `projectfile` and `fsys` (the tools file is read through it, so under `-ssh` it is the remote project's), and nothing else: no tcell, no `ui`. The file half is tested by writing files and reading them back; the running half by running real commands and comparing what came out.
6 6
7 ## One file, and no user-level one7 ## One file, and no user-level one
8 8
@@ -45,6 +45,8 @@ Two things about it are deliberate:
45 - **`onLine` is a parameter of `Start`, not a field.** `Start` begins the goroutine that calls it, so a field assigned afterwards would be a data race — the exact one `terminal.ViewOptions` was created to fix, and not one to reintroduce in a new package.45 - **`onLine` is a parameter of `Start`, not a field.** `Start` begins the goroutine that calls it, so a field assigned afterwards would be a data race — the exact one `terminal.ViewOptions` was created to fix, and not one to reintroduce in a new package.
46 - **Output is capped at 10000 lines**, oldest first, with `Dropped()` reporting how many went. A runaway command would otherwise grow a dialog until it grew the editor; the tail of a failing build is the part that matters, so the head is what goes, and the caller is told rather than quietly shown less.46 - **Output is capped at 10000 lines**, oldest first, with `Dropped()` reporting how many went. A runaway command would otherwise grow a dialog until it grew the editor; the tail of a failing build is the part that matters, so the head is what goes, and the caller is told rather than quietly shown less.
47 47
48+**`StartProgram(command, argv, onLine)`** is the same without the local shell: it runs `argv` as given, under the name `command`. It exists for a tool run on another machine — `app` passes it `session.RunCommand(dir, command, false)`, which is `ssh … /bin/sh -c …` — so the dialog is titled after the command line in the tools file, not after ssh.
49+
48 `output = "terminal"` does not come through here at all: `app` hands that to `terminal` instead, using `Shell()` and `ShellArgs()` from this package so both spell the shell the same way.50 `output = "terminal"` does not come through here at all: `app` hands that to `terminal` instead, using `Shell()` and `ShellArgs()` from this package so both spell the shell the same way.
49 51
50 **Stopping a command stops what it started.** On Unix the command gets a process group of its own (`Setpgid`) and `Stop` kills the group; on Windows it is assigned to a **job object** created with `KILL_ON_JOB_CLOSE`, and `Stop` terminates the job. The Windows assignment happens after the process has started, so a child spawned in those first milliseconds escapes — `exec.Cmd` has no way to start a process suspended. Both live behind the `group` interface in `run.go`; `shell_unix.go`, `shell_windows.go` and `group_other.go` are the three implementations.52 **Stopping a command stops what it started.** On Unix the command gets a process group of its own (`Setpgid`) and `Stop` kills the group; on Windows it is assigned to a **job object** created with `KILL_ON_JOB_CLOSE`, and `Stop` terminates the job. The Windows assignment happens after the process has started, so a child spawned in those first milliseconds escapes — `exec.Cmd` has no way to start a process suspended. Both live behind the `group` interface in `run.go`; `shell_unix.go`, `shell_windows.go` and `group_other.go` are the three implementations.
added tools/fsys_test.go +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+package tools
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestToolsAreReadThroughTheCurrentFileSystem(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+ memory.AddFile("/remote/project/.turbo-test/tools.toml", "[[tool]]\nname = \"Run\"\ncommand = \"python3 main.py\"\n")
15+
16+ p := testProfile()
17+ if !Exists(p, "/remote/project") {
18+ t.Fatal("Exists = false for a tools file that is only in the current file system")
19+ }
20+ list, err := Load(p, "/remote/project")
21+ if err != nil || list.Len() != 1 || list.Tools()[0].Command != "python3 main.py" {
22+ t.Errorf("Load = %+v, %v", list.Tools(), err)
23+ }
24+}
new file mode 100644
@@ -0,0 +1,24 @@
1+package tools
2+
3+import (
4+ "testing"
5+
6+ "rickub.com/turbo-editors/turbo-core/fsys"
7+ "rickub.com/turbo-editors/turbo-core/fsys/fsystest"
8+)
9+
10+func TestToolsAreReadThroughTheCurrentFileSystem(t *testing.T) {
11+ memory := fsystest.New("/remote/project")
12+ previous := fsys.Use(memory)
13+ t.Cleanup(func() { fsys.Use(previous) })
14+ memory.AddFile("/remote/project/.turbo-test/tools.toml", "[[tool]]\nname = \"Run\"\ncommand = \"python3 main.py\"\n")
15+
16+ p := testProfile()
17+ if !Exists(p, "/remote/project") {
18+ t.Fatal("Exists = false for a tools file that is only in the current file system")
19+ }
20+ list, err := Load(p, "/remote/project")
21+ if err != nil || list.Len() != 1 || list.Tools()[0].Command != "python3 main.py" {
22+ t.Errorf("Load = %+v, %v", list.Tools(), err)
23+ }
24+}
modified tools/group_other.go +3 -0
@@ -32,3 +32,6 @@ func newGroup(process *os.Process) group { return loneProcess{process: process}
3232 func (g loneProcess) kill() { _ = g.process.Kill() }
3333
3434 func (loneProcess) release() {}
35+
36+// programAttributes asks for nothing on a platform without process groups.
37+func programAttributes() *syscall.SysProcAttr { return nil }
@@ -32,3 +32,6 @@ func newGroup(process *os.Process) group { return loneProcess{process: process}
32 func (g loneProcess) kill() { _ = g.process.Kill() }32 func (g loneProcess) kill() { _ = g.process.Kill() }
33 33
34 func (loneProcess) release() {}34 func (loneProcess) release() {}
35+
36+// programAttributes asks for nothing on a platform without process groups.
37+func programAttributes() *syscall.SysProcAttr { return nil }
added tools/program_test.go +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+//go:build unix
2+
3+package tools
4+
5+import (
6+ "slices"
7+ "testing"
8+)
9+
10+func TestStartProgramRunsTheArgumentsAsGivenUnderTheNameGiven(t *testing.T) {
11+ // The argument holding a space and a quote would be split or mangled by a
12+ // shell in between; given as one argument, it arrives as one.
13+ run, err := StartProgram("greet", []string{"/bin/sh", "-c", `printf '%s\n' "$0"; exit 3`, "it's one word"}, nil)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ waitUntilDone(t, run)
18+
19+ if run.Command() != "greet" {
20+ t.Errorf("Command() = %q, want the name given", run.Command())
21+ }
22+ if got := run.Lines(); !slices.Equal(got, []string{"it's one word"}) {
23+ t.Errorf("Lines() = %q", got)
24+ }
25+ if _, code := run.Done(); code != 3 {
26+ t.Errorf("exit code = %d, want 3", code)
27+ }
28+}
29+
30+func TestStartProgramRefusesAnEmptyCommandLine(t *testing.T) {
31+ if _, err := StartProgram("nothing", nil, nil); err == nil {
32+ t.Error("StartProgram with no program returned no error")
33+ }
34+}
35+
36+func TestStartProgramCanBeStopped(t *testing.T) {
37+ run, err := StartProgram("sleeper", []string{"/bin/sh", "-c", "sleep 30"}, nil)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ run.Stop()
42+ waitUntilDone(t, run)
43+}
new file mode 100644
@@ -0,0 +1,43 @@
1+//go:build unix
2+
3+package tools
4+
5+import (
6+ "slices"
7+ "testing"
8+)
9+
10+func TestStartProgramRunsTheArgumentsAsGivenUnderTheNameGiven(t *testing.T) {
11+ // The argument holding a space and a quote would be split or mangled by a
12+ // shell in between; given as one argument, it arrives as one.
13+ run, err := StartProgram("greet", []string{"/bin/sh", "-c", `printf '%s\n' "$0"; exit 3`, "it's one word"}, nil)
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ waitUntilDone(t, run)
18+
19+ if run.Command() != "greet" {
20+ t.Errorf("Command() = %q, want the name given", run.Command())
21+ }
22+ if got := run.Lines(); !slices.Equal(got, []string{"it's one word"}) {
23+ t.Errorf("Lines() = %q", got)
24+ }
25+ if _, code := run.Done(); code != 3 {
26+ t.Errorf("exit code = %d, want 3", code)
27+ }
28+}
29+
30+func TestStartProgramRefusesAnEmptyCommandLine(t *testing.T) {
31+ if _, err := StartProgram("nothing", nil, nil); err == nil {
32+ t.Error("StartProgram with no program returned no error")
33+ }
34+}
35+
36+func TestStartProgramCanBeStopped(t *testing.T) {
37+ run, err := StartProgram("sleeper", []string{"/bin/sh", "-c", "sleep 30"}, nil)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ run.Stop()
42+ waitUntilDone(t, run)
43+}
modified tools/run.go +25 -0
@@ -77,6 +77,31 @@ func Start(command, dir string, onLine func()) (*Run, error) {
7777 process := exec.Command(Shell(), ShellArgs(command)...)
7878 process.Dir = dir
7979 process.SysProcAttr = childAttributes(process, command)
80+ return start(command, process, onLine)
81+}
82+
83+// StartProgram runs a program with its arguments as given, no local shell in
84+// between, and returns it in flight. command is what the run is called — what
85+// Command returns and a dialog shows — which is how a tool run on another
86+// machine through `ssh … /bin/sh -c 'command'` is still called by the command
87+// line the tools file holds.
88+//
89+// Everything else is as for Start: output merged, onLine a wake-up only, Stop
90+// ending the program and whatever it started.
91+//
92+// argv := session.RunCommand("/srv/app", "pytest", false)
93+// run, err := tools.StartProgram("pytest", argv, wakeTheEventLoop)
94+func StartProgram(command string, argv []string, onLine func()) (*Run, error) {
95+ if len(argv) == 0 {
96+ return nil, fmt.Errorf("running %s: no program to run", command)
97+ }
98+ process := exec.Command(argv[0], argv[1:]...)
99+ process.SysProcAttr = programAttributes()
100+ return start(command, process, onLine)
101+}
102+
103+// start starts a prepared process, collecting its output.
104+func start(command string, process *exec.Cmd, onLine func()) (*Run, error) {
80105 process.WaitDelay = stopGrace
81106
82107 output, err := process.StdoutPipe()
@@ -77,6 +77,31 @@ func Start(command, dir string, onLine func()) (*Run, error) {
77 process := exec.Command(Shell(), ShellArgs(command)...)77 process := exec.Command(Shell(), ShellArgs(command)...)
78 process.Dir = dir78 process.Dir = dir
79 process.SysProcAttr = childAttributes(process, command)79 process.SysProcAttr = childAttributes(process, command)
80+ return start(command, process, onLine)
81+}
82+
83+// StartProgram runs a program with its arguments as given, no local shell in
84+// between, and returns it in flight. command is what the run is called — what
85+// Command returns and a dialog shows — which is how a tool run on another
86+// machine through `ssh … /bin/sh -c 'command'` is still called by the command
87+// line the tools file holds.
88+//
89+// Everything else is as for Start: output merged, onLine a wake-up only, Stop
90+// ending the program and whatever it started.
91+//
92+// argv := session.RunCommand("/srv/app", "pytest", false)
93+// run, err := tools.StartProgram("pytest", argv, wakeTheEventLoop)
94+func StartProgram(command string, argv []string, onLine func()) (*Run, error) {
95+ if len(argv) == 0 {
96+ return nil, fmt.Errorf("running %s: no program to run", command)
97+ }
98+ process := exec.Command(argv[0], argv[1:]...)
99+ process.SysProcAttr = programAttributes()
100+ return start(command, process, onLine)
101+}
102+
103+// start starts a prepared process, collecting its output.
104+func start(command string, process *exec.Cmd, onLine func()) (*Run, error) {
80 process.WaitDelay = stopGrace105 process.WaitDelay = stopGrace
81 106
82 output, err := process.StdoutPipe()107 output, err := process.StdoutPipe()
modified tools/shell_unix.go +6 -0
@@ -45,3 +45,9 @@ func (g processGroup) kill() {
4545
4646 // release has nothing to give back: a process group is not a resource.
4747 func (processGroup) release() {}
48+
49+// programAttributes puts a program started by StartProgram in a process group
50+// of its own, for the same reason childAttributes does.
51+func programAttributes() *syscall.SysProcAttr {
52+ return &syscall.SysProcAttr{Setpgid: true}
53+}
@@ -45,3 +45,9 @@ func (g processGroup) kill() {
45 45
46 // release has nothing to give back: a process group is not a resource.46 // release has nothing to give back: a process group is not a resource.
47 func (processGroup) release() {}47 func (processGroup) release() {}
48+
49+// programAttributes puts a program started by StartProgram in a process group
50+// of its own, for the same reason childAttributes does.
51+func programAttributes() *syscall.SysProcAttr {
52+ return &syscall.SysProcAttr{Setpgid: true}
53+}
modified tools/shell_windows.go +7 -0
@@ -97,3 +97,10 @@ func (g jobObject) release() {
9797 windows.CloseHandle(g.job)
9898 }
9999 }
100+
101+// programAttributes gives a program started by StartProgram a process group
102+// of its own. Unlike childAttributes it leaves the command line to exec: the
103+// program is not cmd.exe, and the C runtime's quoting is what it expects.
104+func programAttributes() *syscall.SysProcAttr {
105+ return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP}
106+}
@@ -97,3 +97,10 @@ func (g jobObject) release() {
97 windows.CloseHandle(g.job)97 windows.CloseHandle(g.job)
98 }98 }
99 }99 }
100+
101+// programAttributes gives a program started by StartProgram a process group
102+// of its own. Unlike childAttributes it leaves the command line to exec: the
103+// program is not cmd.exe, and the C runtime's quoting is what it expects.
104+func programAttributes() *syscall.SysProcAttr {
105+ return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP}
106+}
modified tools/tools.go +5 -4
@@ -18,12 +18,13 @@ package tools
1818 import (
1919 "errors"
2020 "fmt"
21- "os"
21+ "io/fs"
2222 "path/filepath"
2323 "strings"
2424
2525 "github.com/BurntSushi/toml"
2626
27+ "rickub.com/turbo-editors/turbo-core/fsys"
2728 "rickub.com/turbo-editors/turbo-core/profile"
2829 )
2930
@@ -157,7 +158,7 @@ func Path(p profile.Profile, projectDir string) string {
157158 // A directory in its place counts as absent: it is not something Load could
158159 // have read.
159160 func Exists(p profile.Profile, projectDir string) bool {
160- info, err := os.Stat(Path(p, projectDir))
161+ info, err := fsys.Stat(Path(p, projectDir))
161162 return err == nil && info.Mode().IsRegular()
162163 }
163164
@@ -179,9 +180,9 @@ func Load(p profile.Profile, projectDir string) (List, error) {
179180 path := Path(p, projectDir)
180181 defaultMenu := DefaultMenuName(p)
181182
182- data, err := os.ReadFile(path)
183+ data, err := fsys.ReadFile(path)
183184 if err != nil {
184- if errors.Is(err, os.ErrNotExist) {
185+ if errors.Is(err, fs.ErrNotExist) {
185186 return List{defaultMenu: defaultMenu}, nil
186187 }
187188 return List{defaultMenu: defaultMenu}, fmt.Errorf("reading %s: %w", path, err)
@@ -18,12 +18,13 @@ package tools
18 import (18 import (
19 "errors"19 "errors"
20 "fmt"20 "fmt"
21- "os"21+ "io/fs"
22 "path/filepath"22 "path/filepath"
23 "strings"23 "strings"
24 24
25 "github.com/BurntSushi/toml"25 "github.com/BurntSushi/toml"
26 26
27+ "rickub.com/turbo-editors/turbo-core/fsys"
27 "rickub.com/turbo-editors/turbo-core/profile"28 "rickub.com/turbo-editors/turbo-core/profile"
28 )29 )
29 30
@@ -157,7 +158,7 @@ func Path(p profile.Profile, projectDir string) string {
157 // A directory in its place counts as absent: it is not something Load could158 // A directory in its place counts as absent: it is not something Load could
158 // have read.159 // have read.
159 func Exists(p profile.Profile, projectDir string) bool {160 func Exists(p profile.Profile, projectDir string) bool {
160- info, err := os.Stat(Path(p, projectDir))161+ info, err := fsys.Stat(Path(p, projectDir))
161 return err == nil && info.Mode().IsRegular()162 return err == nil && info.Mode().IsRegular()
162 }163 }
163 164
@@ -179,9 +180,9 @@ func Load(p profile.Profile, projectDir string) (List, error) {
179 path := Path(p, projectDir)180 path := Path(p, projectDir)
180 defaultMenu := DefaultMenuName(p)181 defaultMenu := DefaultMenuName(p)
181 182
182- data, err := os.ReadFile(path)183+ data, err := fsys.ReadFile(path)
183 if err != nil {184 if err != nil {
184- if errors.Is(err, os.ErrNotExist) {185+ if errors.Is(err, fs.ErrNotExist) {
185 return List{defaultMenu: defaultMenu}, nil186 return List{defaultMenu: defaultMenu}, nil
186 }187 }
187 return List{defaultMenu: defaultMenu}, fmt.Errorf("reading %s: %w", path, err)188 return List{defaultMenu: defaultMenu}, fmt.Errorf("reading %s: %w", path, err)