📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25)
fe23288 parent: 3561e52 added
.memory/handoffs/2026-09-20-reload-on-disk-change.md +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +# Handoff — 2026-09-20 — files changed on disk reload into their windows (v1.0.3) | |
| 2 | + | |
| 3 | +## State | |
| 4 | + | |
| 5 | +- Feature in place and green: `app/watch.go`, wired into `tick` and `Run`; `reloadFromDisk` shared with the tools reload. Ten tests, `make check` green, `go test -race ./app` green, quality gate #31 PASS. | |
| 6 | +- `release.env` says `TAG="v1.0.3"`. **Not committed, not tagged.** | |
| 7 | + | |
| 8 | +## Next steps | |
| 9 | + | |
| 10 | +1. Review the diff (`git diff`, `git status`), commit, then `./01-release.tag.sh` from a machine that reaches Rickub. | |
| 11 | +2. Re-pin every editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then each `./01-release.tag.sh`. | |
| 12 | +3. When re-pinning, each editor's user docs could gain one paragraph: files rewritten outside the editor are reloaded; windows with unsaved changes are kept and told. turbo-go's `docs/*/explanation/go-tools.md` and `how-to/run-go-commands.md` already describe the narrower tools reload and are the natural place. | |
| 13 | + | |
| 14 | +## Traps | |
| 15 | + | |
| 16 | +- The stamp is size + mtime. A test that rewrites a file with content of the **same length within the same second** may be invisible on a filesystem with 1 s mtime granularity — every test in `watch_test.go` changes the length for that reason. Do the same in editor-level tests. | |
| 17 | +- `watchFiles` is started by `Run` only. A test driving `tick()` by hand gets no wake and needs none; a test of the wake sets `a.fileWatchInterval` short and polls the simulation screen for `*tcell.EventInterrupt` (`wokeWithin`). | |
| 18 | +- Anything that writes a file on the loop's behalf must call `a.stampFile(path)` afterwards or the next turn will re-read it (harmless — `Reload` compares text — but a wasted read, and a `Reloaded` message would not appear since nothing changed). | |
| 19 | +- Not run against a real terminal in this session (no TTY in the sandbox): the wake → reload → redraw chain is asserted with the simulation screen, never watched. | |
| 20 | + | |
| 21 | +## Also this session | |
| 22 | + | |
| 23 | +The umbrella `turbo-editors/README.md` (one directory up, its own git repository with no `.memory/`) gained a section on testing a turbo-core change from the editors through a root `go.work`. Note the root has no `.gitignore`, so a `go.work` created there shows up as untracked — the section says not to commit it. | |
| 24 | + | |
| 25 | +## Also this session — the agent window's box wraps (same v1.0.3) | |
| 26 | + | |
| 27 | +- `acp/input.go` + `acp/input_test.go`; `drawInput` and the `↑`/`↓` cases in `view_events.go` changed. Everything green (`make check`, quality #32). | |
| 28 | +- **Trap for tests**: a view 30 wide gives rows of 27 runes; the box's first screen row in a 12-high view is row 9. `drawWithCursor` in `input_test.go` is the helper to reuse — the old `draw` does not report the cursor, and the cursor is where the old defect showed. | |
| 29 | +- **Trap for the layout**: the row that breaks keeps its space (`end` is one past it), so a row can be `width+1` runes; `TextLimited` is given `width+1` for that reason. Do not "fix" that to `width` — it would put `…` back. | |
| 30 | +- Not tried on a real terminal (no TTY here). Mouse clicks in the box still only give it the focus; they do not place the cursor — unchanged, and worth doing if somebody asks. | |
| 31 | +- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`, and the same files in the other editors) list the box's keys; when re-pinning, a line saying the box wraps and `↑↓` move by row belongs there. | |
| 32 | + | |
| 33 | +## Also this session — ticket #28, pastes into the agent window (same v1.0.3) | |
| 34 | + | |
| 35 | +- Done and green: `acp/paste.go`, `acp/paste_test.go`, `app/paste.go`, `app/paste_test.go`; `Session.PromptWith`; bracketed paste enabled for the whole editor. Quality #34 PASS. | |
| 36 | +- **Ticket `.tickets/issues/28.yaml` is still `state: open`** — close it once tried in a real terminal. | |
| 37 | +- **Not tried in a real terminal** (no TTY here). Two things to check first: that the terminal actually brackets (iTerm2, Terminal.app, kitty, WezTerm, GNOME Terminal all do; tmux needs `set -g set-clipboard`/passes `\e[200~` through by default), and that a paste with tabs and CRLF lands as one token with the right line count. | |
| 38 | +- **Behaviour to know**: a single-line paste ≤ 200 runes is typed inline (a path, a command); anything longer or with a newline is a token. The thresholds are `pasteInlineLimit` in `acp/paste.go`. A token longer than the box's row wraps at its `·` spaces — ugly but correct; making it unbreakable was left out. | |
| 39 | +- **Trap**: `app/app.go` sits just under qlty's file-complexity threshold (50 → smell). Anything new for the event loop should go in its own file, as `paste.go` did. | |
| 40 | +- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`) should gain: the box wraps, `↑↓` move by row, a long paste becomes a token that Backspace removes whole, Ctrl-V / Shift-Ins paste the editor's clipboard. | |
| 41 | + | |
| 42 | +## Also this session — ticket #25, `configrepo` and `-load-config` in every editor (same v1.0.3) | |
| 43 | + | |
| 44 | +- `configrepo/` here; `-load-config` in each editor's `main.go`. Editors' `go.mod` still pin v1.0.2: **release order matters** — tag turbo-core v1.0.3 first, then in each editor `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then their releases. | |
| 45 | +- To rebuild everything against this checkout meanwhile: from `turbo-editors/`, `go work init ./turbo-core ./turbo-go ./turbo-rust ./turbo-python ./turbo-moonbit ./turbo-golo ./turbo-js ./turbo-go/demo ./turbo-go/bin` — the two nested modules must be listed or `go` fails inside them. Remove `go.work` afterwards; the root has no `.gitignore`. | |
| 46 | +- The sample repository holds only `.turbo-go`; a `-load-config` from turbo-rust against it correctly ends in `ErrNotFound`. Add a `rust-init/.turbo-rust` there to try the others. | |
| 47 | +- `rickub.com/api/v1` wants a PAT even for public reads (measured 401). If that ever changes, an API path could replace the clone; nothing else in the design depends on git. | |
| 48 | +- Ticket #25 is closed in `.tickets/issues/0025-turbo-load-config.yaml`; #28 is still open (left for the user to try first). | |
| 49 | +- **Note on the workspace**: turbo-go already carries its own `go.work` (`.` + `../turbo-core`, dated 2026-09-19, gitignored), so it builds against this checkout on its own; the root workspace is only needed for the five others. Final verification was done that way: root `go.work` over `turbo-core` and the five, removed afterwards. | |
| 50 | +- **Observed, not caused by this session as far as can be told**: during the last test passes the tracked files `turbo-go/demo/{README.md,go.mod,index.html,index.js,main.go}` disappeared from the working tree (git shows `D`; dotfiles in `demo/` survived; no git operation in the reflog; the tree is a kDrive-synced folder on which the user was active — tickets changed at the same time). A 6.9 MB `turbo-go/demo-golang` binary appeared at the same moment. **Nothing was restored**: `git checkout -- demo` in turbo-go puts the files back if the deletion was not deliberate. | |
| 51 | + | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +# Handoff — 2026-09-20 — files changed on disk reload into their windows (v1.0.3) | ||
| 2 | + | ||
| 3 | +## State | ||
| 4 | + | ||
| 5 | +- Feature in place and green: `app/watch.go`, wired into `tick` and `Run`; `reloadFromDisk` shared with the tools reload. Ten tests, `make check` green, `go test -race ./app` green, quality gate #31 PASS. | ||
| 6 | +- `release.env` says `TAG="v1.0.3"`. **Not committed, not tagged.** | ||
| 7 | + | ||
| 8 | +## Next steps | ||
| 9 | + | ||
| 10 | +1. Review the diff (`git diff`, `git status`), commit, then `./01-release.tag.sh` from a machine that reaches Rickub. | ||
| 11 | +2. Re-pin every editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then each `./01-release.tag.sh`. | ||
| 12 | +3. When re-pinning, each editor's user docs could gain one paragraph: files rewritten outside the editor are reloaded; windows with unsaved changes are kept and told. turbo-go's `docs/*/explanation/go-tools.md` and `how-to/run-go-commands.md` already describe the narrower tools reload and are the natural place. | ||
| 13 | + | ||
| 14 | +## Traps | ||
| 15 | + | ||
| 16 | +- The stamp is size + mtime. A test that rewrites a file with content of the **same length within the same second** may be invisible on a filesystem with 1 s mtime granularity — every test in `watch_test.go` changes the length for that reason. Do the same in editor-level tests. | ||
| 17 | +- `watchFiles` is started by `Run` only. A test driving `tick()` by hand gets no wake and needs none; a test of the wake sets `a.fileWatchInterval` short and polls the simulation screen for `*tcell.EventInterrupt` (`wokeWithin`). | ||
| 18 | +- Anything that writes a file on the loop's behalf must call `a.stampFile(path)` afterwards or the next turn will re-read it (harmless — `Reload` compares text — but a wasted read, and a `Reloaded` message would not appear since nothing changed). | ||
| 19 | +- Not run against a real terminal in this session (no TTY in the sandbox): the wake → reload → redraw chain is asserted with the simulation screen, never watched. | ||
| 20 | + | ||
| 21 | +## Also this session | ||
| 22 | + | ||
| 23 | +The umbrella `turbo-editors/README.md` (one directory up, its own git repository with no `.memory/`) gained a section on testing a turbo-core change from the editors through a root `go.work`. Note the root has no `.gitignore`, so a `go.work` created there shows up as untracked — the section says not to commit it. | ||
| 24 | + | ||
| 25 | +## Also this session — the agent window's box wraps (same v1.0.3) | ||
| 26 | + | ||
| 27 | +- `acp/input.go` + `acp/input_test.go`; `drawInput` and the `↑`/`↓` cases in `view_events.go` changed. Everything green (`make check`, quality #32). | ||
| 28 | +- **Trap for tests**: a view 30 wide gives rows of 27 runes; the box's first screen row in a 12-high view is row 9. `drawWithCursor` in `input_test.go` is the helper to reuse — the old `draw` does not report the cursor, and the cursor is where the old defect showed. | ||
| 29 | +- **Trap for the layout**: the row that breaks keeps its space (`end` is one past it), so a row can be `width+1` runes; `TextLimited` is given `width+1` for that reason. Do not "fix" that to `width` — it would put `…` back. | ||
| 30 | +- Not tried on a real terminal (no TTY here). Mouse clicks in the box still only give it the focus; they do not place the cursor — unchanged, and worth doing if somebody asks. | ||
| 31 | +- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`, and the same files in the other editors) list the box's keys; when re-pinning, a line saying the box wraps and `↑↓` move by row belongs there. | ||
| 32 | + | ||
| 33 | +## Also this session — ticket #28, pastes into the agent window (same v1.0.3) | ||
| 34 | + | ||
| 35 | +- Done and green: `acp/paste.go`, `acp/paste_test.go`, `app/paste.go`, `app/paste_test.go`; `Session.PromptWith`; bracketed paste enabled for the whole editor. Quality #34 PASS. | ||
| 36 | +- **Ticket `.tickets/issues/28.yaml` is still `state: open`** — close it once tried in a real terminal. | ||
| 37 | +- **Not tried in a real terminal** (no TTY here). Two things to check first: that the terminal actually brackets (iTerm2, Terminal.app, kitty, WezTerm, GNOME Terminal all do; tmux needs `set -g set-clipboard`/passes `\e[200~` through by default), and that a paste with tabs and CRLF lands as one token with the right line count. | ||
| 38 | +- **Behaviour to know**: a single-line paste ≤ 200 runes is typed inline (a path, a command); anything longer or with a newline is a token. The thresholds are `pasteInlineLimit` in `acp/paste.go`. A token longer than the box's row wraps at its `·` spaces — ugly but correct; making it unbreakable was left out. | ||
| 39 | +- **Trap**: `app/app.go` sits just under qlty's file-complexity threshold (50 → smell). Anything new for the event loop should go in its own file, as `paste.go` did. | ||
| 40 | +- The editors' agent-window docs (turbo-go `docs/*/reference/acp.md`, `how-to/talk-to-an-agent.md`) should gain: the box wraps, `↑↓` move by row, a long paste becomes a token that Backspace removes whole, Ctrl-V / Shift-Ins paste the editor's clipboard. | ||
| 41 | + | ||
| 42 | +## Also this session — ticket #25, `configrepo` and `-load-config` in every editor (same v1.0.3) | ||
| 43 | + | ||
| 44 | +- `configrepo/` here; `-load-config` in each editor's `main.go`. Editors' `go.mod` still pin v1.0.2: **release order matters** — tag turbo-core v1.0.3 first, then in each editor `go get rickub.com/turbo-editors/turbo-core@v1.0.3 && go mod tidy && GOWORK=off make check`, then their releases. | ||
| 45 | +- To rebuild everything against this checkout meanwhile: from `turbo-editors/`, `go work init ./turbo-core ./turbo-go ./turbo-rust ./turbo-python ./turbo-moonbit ./turbo-golo ./turbo-js ./turbo-go/demo ./turbo-go/bin` — the two nested modules must be listed or `go` fails inside them. Remove `go.work` afterwards; the root has no `.gitignore`. | ||
| 46 | +- The sample repository holds only `.turbo-go`; a `-load-config` from turbo-rust against it correctly ends in `ErrNotFound`. Add a `rust-init/.turbo-rust` there to try the others. | ||
| 47 | +- `rickub.com/api/v1` wants a PAT even for public reads (measured 401). If that ever changes, an API path could replace the clone; nothing else in the design depends on git. | ||
| 48 | +- Ticket #25 is closed in `.tickets/issues/0025-turbo-load-config.yaml`; #28 is still open (left for the user to try first). | ||
| 49 | +- **Note on the workspace**: turbo-go already carries its own `go.work` (`.` + `../turbo-core`, dated 2026-09-19, gitignored), so it builds against this checkout on its own; the root workspace is only needed for the five others. Final verification was done that way: root `go.work` over `turbo-core` and the five, removed afterwards. | ||
| 50 | +- **Observed, not caused by this session as far as can be told**: during the last test passes the tracked files `turbo-go/demo/{README.md,go.mod,index.html,index.js,main.go}` disappeared from the working tree (git shows `D`; dotfiles in `demo/` survived; no git operation in the reflog; the tree is a kDrive-synced folder on which the user was active — tickets changed at the same time). A 6.9 MB `turbo-go/demo-golang` binary appeared at the same moment. **Nothing was restored**: `git checkout -- demo` in turbo-go puts the files back if the deletion was not deliberate. | ||
| 51 | + | ||
modified
.memory/history.md +17 -0 | @@ -287,3 +287,20 @@ | ||
| 287 | 287 | - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline. |
| 288 | 288 | - **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`. |
| 289 | 289 | - **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured. |
| 290 | + | |
| 291 | +## 2026-09-20 — a file changed by another program is reloaded into its open window | |
| 292 | + | |
| 293 | +- **Asked**: when a program outside the editor modifies a file that is open in a window, the window should show the change; until now it did not, short of closing and reopening the file (in French). | |
| 294 | +- **Found**: `buffer.Reload` and `reloadAfterTools` already existed, but only ran when a command from the tools menu ended. Nothing looked at the disk otherwise, and the loop sleeps in `PollEvent` between keystrokes. | |
| 295 | +- **Changed**: new `app/watch.go` — `reloadChangedFiles` in `tick` (stamp per open file, canonical key, pruned when windows close), `fileChangedOnDisk`, `reloadFromDisk` (shared with `reloadUnmodifiedBuffers` in `toolchain.go`, now also sending `didChange`), `stampFile`, `fileWatch` + `watchFiles` goroutine started by `Run`. `app.go`: three fields, `tick` step, `Run` starts/stops the watcher. `actions_file.go`: `openBuffer` and `afterSave` stamp. Docs: `app/README.md`, `docs/{en,fr}/reference/app.md`. `release.env` → v1.0.3. | |
| 296 | +- **Decisions**: stat-based stamps, not fsnotify; watcher goroutine wakes only on a difference, not a bare ticker; modified buffers kept with a status-bar notice once per change; vanished files silent. See `summary.md`. | |
| 297 | +- **Tests**: 10 new in `app/watch_test.go`, 6 falsified by commenting the `tick` step out. `make check` green across all 18 packages (including `release_test.go`, which passed in this sandbox this time). `go test -race ./app` green. | |
| 298 | +- **Quality gate**: run #31, PASS, 0 lint, 0 smells. | |
| 299 | +- **Not done**: not committed, not tagged; editors not re-pinned; the editors' own user docs (e.g. turbo-go's `go-tools.md`, which describes the tools reload) do not mention the new behaviour. | |
| 300 | +- **Method note**: the user is not watching this session, so the plan-approval gate of `methodical-dev` was not held; the scope was taken as stated. | |
| 301 | +- **Later, same session**: added a "How to test a turbo-core change without publishing it" section to the umbrella `turbo-editors/README.md` (the directory above this repository): root `go.work` over `./turbo-core` and the editors, the `go list -f '{{.Dir}}'` check, the nested-module trap (`turbo-go/demo`, `turbo-go/bin`), `GOWORK=off`, and the publish sequence. Verified by actually creating the workspace: turbo-go built against this uncommitted checkout, then the workspace was removed. | |
| 302 | +- **Later again, same session — the agent window's box wraps**: asked (in French) for word wrap in the ACP window's input area. Found `drawInput` drawing each typed line with `TextLimited` (cut with `…`) and `ShowCursor` hiding a cursor past the frame. Added `acp/input.go` (`inputRow`, `inputWidth`, `inputRows`, `wrapInputLine`, `inputBreak`, `inputCursor`, `moveInputRow`); `drawInput` rewritten over rows; `↑`/`↓` in `handleInputKey` now `moveInputRow`, `clampColumn` gone. Eight tests in `acp/input_test.go` with a `drawWithCursor` helper reading the simulation screen's cursor; one expectation of mine was wrong (column kept across a row move lands mid-word) and the test was corrected, not the code. `make check` green, quality gate #32 PASS, 0 smells. `acp/README.md` documents it; `release.env` ABOUT now names both changes. Still not committed. | |
| 303 | +- **Later again, same session — ticket #28, pastes into the agent window**: asked to implement `.tickets/issues/28.yaml` (a long paste becomes `[Pasted #1 · 6 lines · 700 chars]` in the box; the model gets the text). Found bracketed paste was never enabled anywhere — a terminal paste arrived as keystrokes, so each newline *sent* a prompt — and the agent view had no way to read the editor's clipboard. Added `acp/paste.go` (+ `Paste` type, `View.Paste`, `OnPaste`, token editing, `expandPastes`), `Session.PromptWith` with `pending.pastes`, `drawInputRow`, `app/paste.go` (`handlePaste`, `pastedText`), `screen.EnablePaste()` in `New`, `handleKey` collecting during a paste, `App.Paste` routing to the agent box, `view.OnPaste = a.clipboard.Text`. Tests: 16 in `acp/paste_test.go`, 5 in `app/paste_test.go`; two of mine had the trace's length wrong (151, not 140) and were corrected. `make check` green, race green, quality #33 FAIL (file complexity of `app/app.go` at 50) → paste handling moved to `app/paste.go` → #34 PASS, 0 smells. Docs: `acp/README.md`, `app/README.md`. Ticket left `open` for the user to close after trying it. Still not committed. | |
| 304 | +- **Correction to the #28 entry above**: bracketed paste *was* enabled — by each editor's own `newScreen` (`screen.EnablePaste()` in `main.go`) — but `app.handle` ignored the `EventPaste` marks, so the keys still streamed through and every Enter sent. What was missing was the handling, not the enabling; `app.New` now enables it too so the library needs nothing from the command. | |
| 305 | +- **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere. | |
| 306 | + | |
| @@ -287,3 +287,20 @@ | |||
| 287 | - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline. | 287 | - **Verified**: `make check` green; whole suite green with `TMPDIR` under a symlink. In turbo-moonbit (through `go.work`), the rewritten `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP` passes (plain and symlinked `TMPDIR`) and **fails against the published v1.0.1** — the falsification. A first version of that test failed for a reason of its own: its `typeText` dropped `\n`, so the four-line fixture became one `///|` doc-comment line and compiled; traced with a tee wrapper around `moon-lsp`. The helper now sends Enter for a newline. |
| 288 | - **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`. | 288 | - **Docs**: `app/README.md`, `docs/{en,fr}/reference/app.md`, `docs/{en,fr}/how-to/talk-to-a-language-server.md`, `lsp/README.md`. |
| 289 | - **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured. | 289 | - **Not done**: not committed, not tagged; `release.env` says v1.0.2. Whether moon-lsp on macOS watches the directory itself is inferred from the user's run, not measured. |
| 290 | + | ||
| 291 | +## 2026-09-20 — a file changed by another program is reloaded into its open window | ||
| 292 | + | ||
| 293 | +- **Asked**: when a program outside the editor modifies a file that is open in a window, the window should show the change; until now it did not, short of closing and reopening the file (in French). | ||
| 294 | +- **Found**: `buffer.Reload` and `reloadAfterTools` already existed, but only ran when a command from the tools menu ended. Nothing looked at the disk otherwise, and the loop sleeps in `PollEvent` between keystrokes. | ||
| 295 | +- **Changed**: new `app/watch.go` — `reloadChangedFiles` in `tick` (stamp per open file, canonical key, pruned when windows close), `fileChangedOnDisk`, `reloadFromDisk` (shared with `reloadUnmodifiedBuffers` in `toolchain.go`, now also sending `didChange`), `stampFile`, `fileWatch` + `watchFiles` goroutine started by `Run`. `app.go`: three fields, `tick` step, `Run` starts/stops the watcher. `actions_file.go`: `openBuffer` and `afterSave` stamp. Docs: `app/README.md`, `docs/{en,fr}/reference/app.md`. `release.env` → v1.0.3. | ||
| 296 | +- **Decisions**: stat-based stamps, not fsnotify; watcher goroutine wakes only on a difference, not a bare ticker; modified buffers kept with a status-bar notice once per change; vanished files silent. See `summary.md`. | ||
| 297 | +- **Tests**: 10 new in `app/watch_test.go`, 6 falsified by commenting the `tick` step out. `make check` green across all 18 packages (including `release_test.go`, which passed in this sandbox this time). `go test -race ./app` green. | ||
| 298 | +- **Quality gate**: run #31, PASS, 0 lint, 0 smells. | ||
| 299 | +- **Not done**: not committed, not tagged; editors not re-pinned; the editors' own user docs (e.g. turbo-go's `go-tools.md`, which describes the tools reload) do not mention the new behaviour. | ||
| 300 | +- **Method note**: the user is not watching this session, so the plan-approval gate of `methodical-dev` was not held; the scope was taken as stated. | ||
| 301 | +- **Later, same session**: added a "How to test a turbo-core change without publishing it" section to the umbrella `turbo-editors/README.md` (the directory above this repository): root `go.work` over `./turbo-core` and the editors, the `go list -f '{{.Dir}}'` check, the nested-module trap (`turbo-go/demo`, `turbo-go/bin`), `GOWORK=off`, and the publish sequence. Verified by actually creating the workspace: turbo-go built against this uncommitted checkout, then the workspace was removed. | ||
| 302 | +- **Later again, same session — the agent window's box wraps**: asked (in French) for word wrap in the ACP window's input area. Found `drawInput` drawing each typed line with `TextLimited` (cut with `…`) and `ShowCursor` hiding a cursor past the frame. Added `acp/input.go` (`inputRow`, `inputWidth`, `inputRows`, `wrapInputLine`, `inputBreak`, `inputCursor`, `moveInputRow`); `drawInput` rewritten over rows; `↑`/`↓` in `handleInputKey` now `moveInputRow`, `clampColumn` gone. Eight tests in `acp/input_test.go` with a `drawWithCursor` helper reading the simulation screen's cursor; one expectation of mine was wrong (column kept across a row move lands mid-word) and the test was corrected, not the code. `make check` green, quality gate #32 PASS, 0 smells. `acp/README.md` documents it; `release.env` ABOUT now names both changes. Still not committed. | ||
| 303 | +- **Later again, same session — ticket #28, pastes into the agent window**: asked to implement `.tickets/issues/28.yaml` (a long paste becomes `[Pasted #1 · 6 lines · 700 chars]` in the box; the model gets the text). Found bracketed paste was never enabled anywhere — a terminal paste arrived as keystrokes, so each newline *sent* a prompt — and the agent view had no way to read the editor's clipboard. Added `acp/paste.go` (+ `Paste` type, `View.Paste`, `OnPaste`, token editing, `expandPastes`), `Session.PromptWith` with `pending.pastes`, `drawInputRow`, `app/paste.go` (`handlePaste`, `pastedText`), `screen.EnablePaste()` in `New`, `handleKey` collecting during a paste, `App.Paste` routing to the agent box, `view.OnPaste = a.clipboard.Text`. Tests: 16 in `acp/paste_test.go`, 5 in `app/paste_test.go`; two of mine had the trace's length wrong (151, not 140) and were corrected. `make check` green, race green, quality #33 FAIL (file complexity of `app/app.go` at 50) → paste handling moved to `app/paste.go` → #34 PASS, 0 smells. Docs: `acp/README.md`, `app/README.md`. Ticket left `open` for the user to close after trying it. Still not committed. | ||
| 304 | +- **Correction to the #28 entry above**: bracketed paste *was* enabled — by each editor's own `newScreen` (`screen.EnablePaste()` in `main.go`) — but `app.handle` ignored the `EventPaste` marks, so the keys still streamed through and every Enter sent. What was missing was the handling, not the enabling; `app.New` now enables it too so the library needs nothing from the command. | ||
| 305 | +- **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere. | ||
| 306 | + | ||
modified
.memory/summary.md +16 -2 | @@ -14,7 +14,7 @@ Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted. | ||
| 14 | 14 | |
| 15 | 15 | ## Architecture |
| 16 | 16 | |
| 17 | -Eighteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one. | |
| 17 | +Nineteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one. | |
| 18 | 18 | |
| 19 | 19 | ``` |
| 20 | 20 | app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version} |
| @@ -23,6 +23,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t | ||
| 23 | 23 | filetree → {ui, theme, tcell} |
| 24 | 24 | acp → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml} |
| 25 | 25 | settings │ snippets │ tools → {toml, projectfile, profile} |
| 26 | + configrepo → {projectfile, profile} ← execs git | |
| 26 | 27 | syntax → theme |
| 27 | 28 | ui → theme |
| 28 | 29 | lsp → {jsonrpc, profile} |
| @@ -35,6 +36,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t | ||
| 35 | 36 | | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** | |
| 36 | 37 | | `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** | |
| 37 | 38 | | `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** | |
| 39 | +| `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command | | |
| 38 | 40 | | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** | |
| 39 | 41 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` | |
| 40 | 42 | | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` | |
| @@ -129,6 +131,14 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve | ||
| 129 | 131 | |
| 130 | 132 | - **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`. |
| 131 | 133 | |
| 134 | +- **A file rewritten by another program is reloaded into its window (2026-09-20, v1.0.3).** `app/watch.go`: `reloadChangedFiles` runs in `tick`, keeps a `fileStamp` (size + mtime, the guard `refreshToolMenus` already used) per open file by canonical path in `a.fileStamps`, and re-reads through `reloadFromDisk` — now shared with the tools reload, so a reload also sends `didChange` to the server, which the tools reload never did. The editor's own writes re-stamp (`openBuffer`, `afterSave`, the reload), so a save is not mistaken for a change. **A modified buffer is never reloaded**; the status bar says `changed on disk; your unsaved changes are kept`, once per change. A file that vanishes is left alone silently (delete-then-write replacers exist). Because the loop sleeps in `PollEvent`, `Run` — and only `Run` — starts a `fileWatch` goroutine that stats the published stamps every `fileWatchInterval` (1 s) and posts a wake when one differs: a nudge only, the loop decides by itself, and an idle editor with unchanged files is never woken. Rejected: fsnotify (a dependency, platform-specific limits, and still needs the state-driven check); a bare 1 Hz ticker (redraws an idle editor forever). Same-second rewrite of the same size is invisible to the stamp — accepted, as every stamp-based editor does. Ten tests in `app/watch_test.go`; six fail with the step removed from `tick`. | |
| 135 | + | |
| 136 | +- **The agent window's box wraps what you type (2026-09-20, v1.0.3).** `acp/input.go`: the typed lines (`View.input`, the ones Alt-Enter makes) stay the model; `inputRows` lays them out as `inputRow{line,start,end}` at `inputWidth()` = `W-3` on every frame and every arrow key, nothing stored. A row breaks *after* the last space that fits (the space stays on the row, undrawn), so rows partition the line's runes and `inputCursor` is unambiguous; the third reserved column is for the cursor just past a full row, which `Painter.ShowCursor` otherwise hides as out of clip. `↑`/`↓` move by visual row (`moveInputRow`), falling through to the transcript off the top; `Home`/`End` stay per logical line. The rows drawn end with the cursor's row; `>` is drawn only when the first row is visible. Before: `TextLimited` cut the line with `…` and the cursor vanished. Rejected: wrapping the model itself (would make Backspace at a wrap join nothing and the wire carry soft breaks); reusing `acp.Wrap` (it trims spaces, losing the rune offsets the cursor needs). Eight tests in `acp/input_test.go`, asserting on the simulation screen's cursor. | |
| 137 | + | |
| 138 | +- **A long paste into the agent window is a token in the box and its text on the wire (2026-09-20, v1.0.3, ticket #28).** `acp/paste.go`: `View.Paste(text)` types a single line of ≤ `pasteInlineLimit` (200) runes as if typed, and otherwise keeps it in `View.pastes` and inserts `[Pasted #N · L lines · C chars]`; `Send` calls the new `Session.PromptWith(text, pastes, mentions…)` — `Prompt` is now a wrapper — which keeps the token text in the transcript and has `expandPastes` replace tokens **inside text blocks only, after `blocksFor`** (so an `@name` in a pasted log is never a mention; a paste is byte for byte). Tokens are found by text (`tokensIn`), never by remembered position; Backspace/Delete remove one whole, `←`/`→` step over, `↑`/`↓` landing inside snap to the nearer edge; drawn in the type style (`drawInputRow`, rune by rune). Numbers run on per window. **Two ways in**: bracketed paste — `app.New` calls `screen.EnablePaste()`, `handleKey` collects keys while `a.pasting`, `handlePaste` (`app/paste.go`) hands an agent window the whole text (`pastedText`: rune/Enter→`\n`/Tab→`\t`) and replays keys to any other window, so files are typed exactly as before — and the editor's clipboard via `View.OnPaste` (Ctrl-V, Shift-Ins) and `App.Paste` (Edit ▸ Paste) routing to the agent's box. Before this, pasting forty lines into the box sent forty prompts, one per Enter. Rejected: expanding tokens in the text before `blocksFor` (mentions read out of pastes); a per-prompt numbering (two `#1`s in one window); making the tokens unbreakable across a wrap (a 34-rune token still wraps at its `·` spaces — accepted). Sixteen tests in `acp/paste_test.go`, five in `app/paste_test.go`. | |
| 139 | + | |
| 140 | +- **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor. | |
| 141 | + | |
| 132 | 142 | ## Build, test, run |
| 133 | 143 | |
| 134 | 144 | ```bash |
| @@ -164,7 +174,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace . | ||
| 164 | 174 | |
| 165 | 175 | - **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release. |
| 166 | 176 | - **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it. |
| 167 | -- **v1.0.2 is ready to tag** (`release.env` says so): `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing. | |
| 177 | +- **v1.0.2 is released**: `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing. | |
| 178 | + | |
| 179 | +## State as of 2026-09-20 | |
| 180 | + | |
| 181 | +- **v1.0.3 is ready to tag** (`release.env` says so): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25) — all above. **All six editors already carry `-load-config`** against this checkout; each still pins v1.0.2 and builds only under the umbrella `go.work` until re-pinned. `make check` green, `go test -race ./acp ./app` green, quality gate run #34 PASS with zero smells (run #33 failed on `app/app.go` file complexity; moving the paste handling into `app/paste.go` cleared it). **Not committed, not tagged.** Every editor should re-pin to pick both up; their user docs do not yet mention either behaviour (the agent-window docs — turbo-go's `reference/acp.md`, `how-to/talk-to-an-agent.md` — list the box's keys and could say it wraps and that `↑↓` move by row). | |
| 168 | 182 | |
| 169 | 183 | ## Not yet established |
| 170 | 184 | |
| @@ -14,7 +14,7 @@ Extracted from turbo-go on 2026-09-01, at the point a second editor was wanted. | |||
| 14 | 14 | ||
| 15 | ## Architecture | 15 | ## Architecture |
| 16 | 16 | ||
| 17 | -Eighteen packages. Dependencies run strictly downwards, no cycles, and no interface indirection introduced to prevent one. | 17 | +Nineteen packages. 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} |
| @@ -23,6 +23,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t | |||
| 23 | filetree → {ui, theme, tcell} | 23 | filetree → {ui, theme, tcell} |
| 24 | acp → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml} | 24 | acp → {jsonrpc, profile, projectfile, syntax, theme, ui, tcell, toml} |
| 25 | settings │ snippets │ tools → {toml, projectfile, profile} | 25 | settings │ snippets │ tools → {toml, projectfile, profile} |
| 26 | + configrepo → {projectfile, profile} ← execs git | ||
| 26 | syntax → theme | 27 | syntax → theme |
| 27 | ui → theme | 28 | ui → theme |
| 28 | lsp → {jsonrpc, profile} | 29 | lsp → {jsonrpc, profile} |
| @@ -35,6 +36,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t | |||
| 35 | | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** | | 36 | | `profile` | The editor's identity: name, slug, language, tools menu, root markers, server, templates — and every path derived from the slug | **stdlib only** | |
| 36 | | `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** | | 37 | | `buffer` | Text of one file: lines of runes, cursor, selection, undo, search, load/save | **stdlib only** | |
| 37 | | `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** | | 38 | | `projectfile` | Atomic writes of a project's own TOML files | **stdlib only** | |
| 39 | +| `configrepo` | A shared `.turbo-<slug>` fetched from a forge URL by a shallow git clone (`-load-config`) | `projectfile`, `profile`, the `git` command | | ||
| 38 | | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** | | 40 | | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** | |
| 39 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` | | 41 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` | |
| 40 | | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` | | 42 | | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` | |
| @@ -129,6 +131,14 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve | |||
| 129 | 131 | ||
| 130 | - **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`. | 132 | - **A save that creates a file says so to the server (2026-09-19, v1.0.2).** `lsp.Client.FileCreated` sends `workspace/didChangeWatchedFiles` (type 1); `app.announceSaved` sends it after the document's `didOpen`/`didSave` whenever the save created the file (`fileExists` asked before the write, in both `save` and `writeQuietly`). Reason: moon-lsp lists a package's files from the directory, so a `.mbt` saved for the first time — `turbo-moonbit new.mbt`, type, save — was known as a document and never diagnosed. Verified at the protocol level (every order of didOpen/didSave/didChangeWatchedFiles gets the file diagnosed; nothing does without it) and by Turbo MoonBit's `TestAFileCreatedInTheEditorIsDiagnosedFromItsFirstSaveWithRealMoonLSP`, which fails against v1.0.1 and passes against this. Sent unasked (moon-lsp registers no watcher); harmless to the fake and, by the spec, to any server. Only `Created` is ever sent: Save As leaves the old file on disk, so no `Deleted`. |
| 131 | 133 | ||
| 134 | +- **A file rewritten by another program is reloaded into its window (2026-09-20, v1.0.3).** `app/watch.go`: `reloadChangedFiles` runs in `tick`, keeps a `fileStamp` (size + mtime, the guard `refreshToolMenus` already used) per open file by canonical path in `a.fileStamps`, and re-reads through `reloadFromDisk` — now shared with the tools reload, so a reload also sends `didChange` to the server, which the tools reload never did. The editor's own writes re-stamp (`openBuffer`, `afterSave`, the reload), so a save is not mistaken for a change. **A modified buffer is never reloaded**; the status bar says `changed on disk; your unsaved changes are kept`, once per change. A file that vanishes is left alone silently (delete-then-write replacers exist). Because the loop sleeps in `PollEvent`, `Run` — and only `Run` — starts a `fileWatch` goroutine that stats the published stamps every `fileWatchInterval` (1 s) and posts a wake when one differs: a nudge only, the loop decides by itself, and an idle editor with unchanged files is never woken. Rejected: fsnotify (a dependency, platform-specific limits, and still needs the state-driven check); a bare 1 Hz ticker (redraws an idle editor forever). Same-second rewrite of the same size is invisible to the stamp — accepted, as every stamp-based editor does. Ten tests in `app/watch_test.go`; six fail with the step removed from `tick`. | ||
| 135 | + | ||
| 136 | +- **The agent window's box wraps what you type (2026-09-20, v1.0.3).** `acp/input.go`: the typed lines (`View.input`, the ones Alt-Enter makes) stay the model; `inputRows` lays them out as `inputRow{line,start,end}` at `inputWidth()` = `W-3` on every frame and every arrow key, nothing stored. A row breaks *after* the last space that fits (the space stays on the row, undrawn), so rows partition the line's runes and `inputCursor` is unambiguous; the third reserved column is for the cursor just past a full row, which `Painter.ShowCursor` otherwise hides as out of clip. `↑`/`↓` move by visual row (`moveInputRow`), falling through to the transcript off the top; `Home`/`End` stay per logical line. The rows drawn end with the cursor's row; `>` is drawn only when the first row is visible. Before: `TextLimited` cut the line with `…` and the cursor vanished. Rejected: wrapping the model itself (would make Backspace at a wrap join nothing and the wire carry soft breaks); reusing `acp.Wrap` (it trims spaces, losing the rune offsets the cursor needs). Eight tests in `acp/input_test.go`, asserting on the simulation screen's cursor. | ||
| 137 | + | ||
| 138 | +- **A long paste into the agent window is a token in the box and its text on the wire (2026-09-20, v1.0.3, ticket #28).** `acp/paste.go`: `View.Paste(text)` types a single line of ≤ `pasteInlineLimit` (200) runes as if typed, and otherwise keeps it in `View.pastes` and inserts `[Pasted #N · L lines · C chars]`; `Send` calls the new `Session.PromptWith(text, pastes, mentions…)` — `Prompt` is now a wrapper — which keeps the token text in the transcript and has `expandPastes` replace tokens **inside text blocks only, after `blocksFor`** (so an `@name` in a pasted log is never a mention; a paste is byte for byte). Tokens are found by text (`tokensIn`), never by remembered position; Backspace/Delete remove one whole, `←`/`→` step over, `↑`/`↓` landing inside snap to the nearer edge; drawn in the type style (`drawInputRow`, rune by rune). Numbers run on per window. **Two ways in**: bracketed paste — `app.New` calls `screen.EnablePaste()`, `handleKey` collects keys while `a.pasting`, `handlePaste` (`app/paste.go`) hands an agent window the whole text (`pastedText`: rune/Enter→`\n`/Tab→`\t`) and replays keys to any other window, so files are typed exactly as before — and the editor's clipboard via `View.OnPaste` (Ctrl-V, Shift-Ins) and `App.Paste` (Edit ▸ Paste) routing to the agent's box. Before this, pasting forty lines into the box sent forty prompts, one per Enter. Rejected: expanding tokens in the text before `blocksFor` (mentions read out of pastes); a per-prompt numbering (two `#1`s in one window); making the tokens unbreakable across a wrap (a 34-rune token still wraps at its `·` spaces — accepted). Sixteen tests in `acp/paste_test.go`, five in `app/paste_test.go`. | ||
| 139 | + | ||
| 140 | +- **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor. | ||
| 141 | + | ||
| 132 | ## Build, test, run | 142 | ## Build, test, run |
| 133 | 143 | ||
| 134 | ```bash | 144 | ```bash |
| @@ -164,7 +174,11 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace . | |||
| 164 | 174 | ||
| 165 | - **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release. | 175 | - **v1.0.0 is released** under the new module path `rickub.com/turbo-editors/turbo-core`, by `01-release.tag.sh` and the Release workflow; turbo-go v1.0.0 is published against it and the five other editors are re-pinned to it, waiting for their first Rickub release. |
| 166 | - **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it. | 176 | - **v1.0.1 is released**: the canonical-path fix (`lsp.CanonicalPath`, `pathKey`, `windowFor`). turbo-moonbit is re-pinned to it. |
| 167 | -- **v1.0.2 is ready to tag** (`release.env` says so): `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing. | 177 | +- **v1.0.2 is released**: `FileCreated` above; four new tests (`lsp` ×1 in the notification-order test, `app` ×3 in `save_test.go`), the fake server records the last `didOpen` and the last file event and the order of every method. Turbo MoonBit's suite needs it (its new first-save test fails on v1.0.1); every editor should re-pin before releasing. |
| 178 | + | ||
| 179 | +## State as of 2026-09-20 | ||
| 180 | + | ||
| 181 | +- **v1.0.3 is ready to tag** (`release.env` says so): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25) — all above. **All six editors already carry `-load-config`** against this checkout; each still pins v1.0.2 and builds only under the umbrella `go.work` until re-pinned. `make check` green, `go test -race ./acp ./app` green, quality gate run #34 PASS with zero smells (run #33 failed on `app/app.go` file complexity; moving the paste handling into `app/paste.go` cleared it). **Not committed, not tagged.** Every editor should re-pin to pick both up; their user docs do not yet mention either behaviour (the agent-window docs — turbo-go's `reference/acp.md`, `how-to/talk-to-an-agent.md` — list the box's keys and could say it wraps and that `↑↓` move by row). | ||
| 168 | 182 | ||
| 169 | ## Not yet established | 183 | ## Not yet established |
| 170 | 184 | ||
modified
.quality/history.jsonl +6 -0 | @@ -28,3 +28,9 @@ | ||
| 28 | 28 | {"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 199, "complex": 2305, "cyclo": 5161, "fields": 594, "funcs": 1497, "lcom": 0, "lines": 24812, "loc": 15266}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 28, "smells": 1, "timestamp": "2026-09-17T04:41:36Z"} |
| 29 | 29 | {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2306, "cyclo": 5165, "fields": 594, "funcs": 1499, "lcom": 0, "lines": 24833, "loc": 15281}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 29, "smells": 0, "timestamp": "2026-09-17T04:42:34Z"} |
| 30 | 30 | {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2308, "cyclo": 5170, "fields": 594, "funcs": 1501, "lcom": 0, "lines": 24873, "loc": 15295}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 30, "smells": 0, "timestamp": "2026-09-18T19:50:22Z"} |
| 31 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 203, "complex": 2339, "cyclo": 5201, "fields": 601, "funcs": 1513, "lcom": 0, "lines": 25173, "loc": 15445}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 31, "smells": 0, "timestamp": "2026-09-20T02:58:30Z"} | |
| 32 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 204, "complex": 2353, "cyclo": 5238, "fields": 604, "funcs": 1518, "lcom": 0, "lines": 25285, "loc": 15506}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 32, "smells": 0, "timestamp": "2026-09-20T03:21:36Z"} | |
| 33 | +{"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 206, "complex": 2405, "cyclo": 5315, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25641, "loc": 15725}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 33, "smells": 1, "timestamp": "2026-09-20T04:09:00Z"} | |
| 34 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"} | |
| 35 | +{"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"} | |
| 36 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"} | |
| @@ -28,3 +28,9 @@ | |||
| 28 | {"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 199, "complex": 2305, "cyclo": 5161, "fields": 594, "funcs": 1497, "lcom": 0, "lines": 24812, "loc": 15266}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 28, "smells": 1, "timestamp": "2026-09-17T04:41:36Z"} | 28 | {"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 199, "complex": 2305, "cyclo": 5161, "fields": 594, "funcs": 1497, "lcom": 0, "lines": 24812, "loc": 15266}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 28, "smells": 1, "timestamp": "2026-09-17T04:41:36Z"} |
| 29 | {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2306, "cyclo": 5165, "fields": 594, "funcs": 1499, "lcom": 0, "lines": 24833, "loc": 15281}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 29, "smells": 0, "timestamp": "2026-09-17T04:42:34Z"} | 29 | {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2306, "cyclo": 5165, "fields": 594, "funcs": 1499, "lcom": 0, "lines": 24833, "loc": 15281}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 29, "smells": 0, "timestamp": "2026-09-17T04:42:34Z"} |
| 30 | {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2308, "cyclo": 5170, "fields": 594, "funcs": 1501, "lcom": 0, "lines": 24873, "loc": 15295}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 30, "smells": 0, "timestamp": "2026-09-18T19:50:22Z"} | 30 | {"branch": "main", "breaches": [], "commit": "c845294", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 199, "complex": 2308, "cyclo": 5170, "fields": 594, "funcs": 1501, "lcom": 0, "lines": 24873, "loc": 15295}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 30, "smells": 0, "timestamp": "2026-09-18T19:50:22Z"} |
| 31 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 203, "complex": 2339, "cyclo": 5201, "fields": 601, "funcs": 1513, "lcom": 0, "lines": 25173, "loc": 15445}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 31, "smells": 0, "timestamp": "2026-09-20T02:58:30Z"} | ||
| 32 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 204, "complex": 2353, "cyclo": 5238, "fields": 604, "funcs": 1518, "lcom": 0, "lines": 25285, "loc": 15506}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 32, "smells": 0, "timestamp": "2026-09-20T03:21:36Z"} | ||
| 33 | +{"branch": "main", "breaches": ["code smells: 1 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 206, "complex": 2405, "cyclo": 5315, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25641, "loc": 15725}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 33, "smells": 1, "timestamp": "2026-09-20T04:09:00Z"} | ||
| 34 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"} | ||
| 35 | +{"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"} | ||
| 36 | +{"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"} | ||
added
.quality/report-20260920T025830Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-20T02:58:30Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #31 (previous: 2026-09-18T19:50:22Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1513 | +12 | | |
| 31 | +| classes | 203 | +4 | | |
| 32 | +| fields | 601 | +7 | | |
| 33 | +| cyclo | 5201 | +31 | | |
| 34 | +| complex | 2339 | +31 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 25173 | +300 | | |
| 37 | +| loc | 15445 | +150 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| app/agents.go | 45 | 94 | 248 | | |
| 46 | +| theme/load.go | 44 | 71 | 214 | | |
| 47 | +| app/app.go | 43 | 123 | 383 | | |
| 48 | +| acp/session.go | 39 | 88 | 414 | | |
| 49 | +| acp/picker.go | 38 | 83 | 203 | | |
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 54 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 55 | +| ui/window.go | 35 | 110 | 251 | | |
| 56 | +| acp/config.go | 34 | 43 | 149 | | |
| 57 | +| snippets/snippets.go | 34 | 39 | 135 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS | | |
| 64 | +| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL | | |
| 65 | +| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | |
| 66 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | |
| 67 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | |
| 68 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 69 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 70 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 71 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 72 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-20T02:58:30Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `3561e52` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #31 (previous: 2026-09-18T19:50:22Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: ±0) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1513 | +12 | | ||
| 31 | +| classes | 203 | +4 | | ||
| 32 | +| fields | 601 | +7 | | ||
| 33 | +| cyclo | 5201 | +31 | | ||
| 34 | +| complex | 2339 | +31 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 25173 | +300 | | ||
| 37 | +| loc | 15445 | +150 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| app/agents.go | 45 | 94 | 248 | | ||
| 46 | +| theme/load.go | 44 | 71 | 214 | | ||
| 47 | +| app/app.go | 43 | 123 | 383 | | ||
| 48 | +| acp/session.go | 39 | 88 | 414 | | ||
| 49 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 54 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 55 | +| ui/window.go | 35 | 110 | 251 | | ||
| 56 | +| acp/config.go | 34 | 43 | 149 | | ||
| 57 | +| snippets/snippets.go | 34 | 39 | 135 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS | | ||
| 64 | +| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL | | ||
| 65 | +| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | ||
| 66 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | ||
| 67 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | ||
| 68 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 69 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 70 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 71 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 72 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
added
.quality/report-20260920T032136Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-20T03:21:36Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #32 (previous: 2026-09-20T02:58:30Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1518 | +5 | | |
| 31 | +| classes | 204 | +1 | | |
| 32 | +| fields | 604 | +3 | | |
| 33 | +| cyclo | 5238 | +37 | | |
| 34 | +| complex | 2353 | +14 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 25285 | +112 | | |
| 37 | +| loc | 15506 | +61 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| app/agents.go | 45 | 94 | 248 | | |
| 46 | +| theme/load.go | 44 | 71 | 214 | | |
| 47 | +| app/app.go | 43 | 123 | 383 | | |
| 48 | +| acp/session.go | 39 | 88 | 414 | | |
| 49 | +| acp/picker.go | 38 | 83 | 203 | | |
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 54 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 55 | +| ui/window.go | 35 | 110 | 251 | | |
| 56 | +| acp/config.go | 34 | 43 | 149 | | |
| 57 | +| acp/view_draw.go | 34 | 81 | 161 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL | | |
| 64 | +| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | |
| 65 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | |
| 66 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | |
| 67 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 68 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 69 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 70 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 71 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 72 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-20T03:21:36Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `3561e52` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #32 (previous: 2026-09-20T02:58:30Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: ±0) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1518 | +5 | | ||
| 31 | +| classes | 204 | +1 | | ||
| 32 | +| fields | 604 | +3 | | ||
| 33 | +| cyclo | 5238 | +37 | | ||
| 34 | +| complex | 2353 | +14 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 25285 | +112 | | ||
| 37 | +| loc | 15506 | +61 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| app/agents.go | 45 | 94 | 248 | | ||
| 46 | +| theme/load.go | 44 | 71 | 214 | | ||
| 47 | +| app/app.go | 43 | 123 | 383 | | ||
| 48 | +| acp/session.go | 39 | 88 | 414 | | ||
| 49 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 54 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 55 | +| ui/window.go | 35 | 110 | 251 | | ||
| 56 | +| acp/config.go | 34 | 43 | 149 | | ||
| 57 | +| acp/view_draw.go | 34 | 81 | 161 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL | | ||
| 64 | +| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | ||
| 65 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | ||
| 66 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | ||
| 67 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 68 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 69 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 70 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 71 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 72 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
added
.quality/report-20260920T040900Z.md +78 -0 | new file mode 100644 | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +# Quality report — 2026-09-20T04:09:00Z | |
| 2 | + | |
| 3 | +- **Gate**: ❌ **FAIL** | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #33 (previous: 2026-09-20T03:21:36Z) | |
| 7 | + | |
| 8 | +## Gate violations | |
| 9 | + | |
| 10 | +- code smells: 1 (max 0) | |
| 11 | + | |
| 12 | +## Lint issues (`qlty check`) | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Top rules | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +### Most affected files | |
| 21 | + | |
| 22 | +_none_ | |
| 23 | + | |
| 24 | +## Code smells (`qlty smells`) | |
| 25 | + | |
| 26 | +Total: **1** (vs previous: +1) | |
| 27 | + | |
| 28 | +| smell | file | line | detail | | |
| 29 | +|---|---|---|---| | |
| 30 | +| qlty:file-complexity | app/app.go | 1 | High total complexity (count = 50) | | |
| 31 | + | |
| 32 | +## Metrics (`qlty metrics`) | |
| 33 | + | |
| 34 | +| metric | total | vs previous | | |
| 35 | +|---|---|---| | |
| 36 | +| funcs | 1536 | +18 | | |
| 37 | +| classes | 206 | +2 | | |
| 38 | +| fields | 614 | +10 | | |
| 39 | +| cyclo | 5315 | +77 | | |
| 40 | +| complex | 2405 | +52 | | |
| 41 | +| lcom | 0 | ±0 | | |
| 42 | +| lines | 25641 | +356 | | |
| 43 | +| loc | 15725 | +219 | | |
| 44 | + | |
| 45 | +### Most complex files | |
| 46 | + | |
| 47 | +| file | complex | cyclo | loc | | |
| 48 | +|---|---|---|---| | |
| 49 | +| app/app.go | 50 | 135 | 422 | | |
| 50 | +| editor/view.go | 48 | 124 | 380 | | |
| 51 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 52 | +| app/agents.go | 45 | 94 | 249 | | |
| 53 | +| theme/load.go | 44 | 71 | 214 | | |
| 54 | +| acp/session.go | 39 | 88 | 418 | | |
| 55 | +| acp/picker.go | 38 | 83 | 203 | | |
| 56 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 57 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 58 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 59 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 60 | +| acp/paste.go | 37 | 53 | 135 | | |
| 61 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 62 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 63 | +| ui/window.go | 35 | 110 | 251 | | |
| 64 | + | |
| 65 | +## Trend | |
| 66 | + | |
| 67 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 68 | +|---|---|---|---|---|---|---| | |
| 69 | +| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | |
| 70 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | |
| 71 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | |
| 72 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 73 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 74 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 75 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 76 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 77 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 78 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| new file mode 100644 | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | +# Quality report — 2026-09-20T04:09:00Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ❌ **FAIL** | ||
| 4 | +- **Commit**: `3561e52` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #33 (previous: 2026-09-20T03:21:36Z) | ||
| 7 | + | ||
| 8 | +## Gate violations | ||
| 9 | + | ||
| 10 | +- code smells: 1 (max 0) | ||
| 11 | + | ||
| 12 | +## Lint issues (`qlty check`) | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Top rules | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +### Most affected files | ||
| 21 | + | ||
| 22 | +_none_ | ||
| 23 | + | ||
| 24 | +## Code smells (`qlty smells`) | ||
| 25 | + | ||
| 26 | +Total: **1** (vs previous: +1) | ||
| 27 | + | ||
| 28 | +| smell | file | line | detail | | ||
| 29 | +|---|---|---|---| | ||
| 30 | +| qlty:file-complexity | app/app.go | 1 | High total complexity (count = 50) | | ||
| 31 | + | ||
| 32 | +## Metrics (`qlty metrics`) | ||
| 33 | + | ||
| 34 | +| metric | total | vs previous | | ||
| 35 | +|---|---|---| | ||
| 36 | +| funcs | 1536 | +18 | | ||
| 37 | +| classes | 206 | +2 | | ||
| 38 | +| fields | 614 | +10 | | ||
| 39 | +| cyclo | 5315 | +77 | | ||
| 40 | +| complex | 2405 | +52 | | ||
| 41 | +| lcom | 0 | ±0 | | ||
| 42 | +| lines | 25641 | +356 | | ||
| 43 | +| loc | 15725 | +219 | | ||
| 44 | + | ||
| 45 | +### Most complex files | ||
| 46 | + | ||
| 47 | +| file | complex | cyclo | loc | | ||
| 48 | +|---|---|---|---| | ||
| 49 | +| app/app.go | 50 | 135 | 422 | | ||
| 50 | +| editor/view.go | 48 | 124 | 380 | | ||
| 51 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 52 | +| app/agents.go | 45 | 94 | 249 | | ||
| 53 | +| theme/load.go | 44 | 71 | 214 | | ||
| 54 | +| acp/session.go | 39 | 88 | 418 | | ||
| 55 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 56 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 57 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 58 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 59 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 60 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 61 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 62 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 63 | +| ui/window.go | 35 | 110 | 251 | | ||
| 64 | + | ||
| 65 | +## Trend | ||
| 66 | + | ||
| 67 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 68 | +|---|---|---|---|---|---|---| | ||
| 69 | +| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | ||
| 70 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | ||
| 71 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | ||
| 72 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 73 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 74 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 75 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 76 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 77 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 78 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
added
.quality/report-20260920T040935Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-20T04:09:35Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #34 (previous: 2026-09-20T04:09:00Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: -1) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1536 | ±0 | | |
| 31 | +| classes | 206 | ±0 | | |
| 32 | +| fields | 614 | ±0 | | |
| 33 | +| cyclo | 5316 | +1 | | |
| 34 | +| complex | 2406 | +1 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 25650 | +9 | | |
| 37 | +| loc | 15729 | +4 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| app/agents.go | 45 | 94 | 249 | | |
| 46 | +| app/app.go | 44 | 125 | 392 | | |
| 47 | +| theme/load.go | 44 | 71 | 214 | | |
| 48 | +| acp/session.go | 39 | 88 | 418 | | |
| 49 | +| acp/picker.go | 38 | 83 | 203 | | |
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 54 | +| acp/paste.go | 37 | 53 | 135 | | |
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 57 | +| ui/window.go | 35 | 110 | 251 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | |
| 64 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | |
| 65 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 66 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 67 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 68 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 69 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 70 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 71 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 72 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-20T04:09:35Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `3561e52` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #34 (previous: 2026-09-20T04:09:00Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: -1) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1536 | ±0 | | ||
| 31 | +| classes | 206 | ±0 | | ||
| 32 | +| fields | 614 | ±0 | | ||
| 33 | +| cyclo | 5316 | +1 | | ||
| 34 | +| complex | 2406 | +1 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 25650 | +9 | | ||
| 37 | +| loc | 15729 | +4 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| app/agents.go | 45 | 94 | 249 | | ||
| 46 | +| app/app.go | 44 | 125 | 392 | | ||
| 47 | +| theme/load.go | 44 | 71 | 214 | | ||
| 48 | +| acp/session.go | 39 | 88 | 418 | | ||
| 49 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 54 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 57 | +| ui/window.go | 35 | 110 | 251 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | ||
| 64 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | ||
| 65 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 66 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 67 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 68 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 69 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 70 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 71 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 72 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
added
.quality/report-20260920T070134Z.md +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +# Quality report — 2026-09-20T07:01:34Z | |
| 2 | + | |
| 3 | +- **Gate**: ❌ **FAIL** | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #35 (previous: 2026-09-20T04:09:35Z) | |
| 7 | + | |
| 8 | +## Gate violations | |
| 9 | + | |
| 10 | +- code smells: 2 (max 0) | |
| 11 | + | |
| 12 | +## Lint issues (`qlty check`) | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Top rules | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +### Most affected files | |
| 21 | + | |
| 22 | +_none_ | |
| 23 | + | |
| 24 | +## Code smells (`qlty smells`) | |
| 25 | + | |
| 26 | +Total: **2** (vs previous: +2) | |
| 27 | + | |
| 28 | +| smell | file | line | detail | | |
| 29 | +|---|---|---|---| | |
| 30 | +| qlty:return-statements | configrepo/configrepo.go | 166 | Function with many returns (count = 8): loadFrom | | |
| 31 | +| qlty:return-statements | configrepo/copy.go | 21 | Function with many returns (count = 8): copyTree | | |
| 32 | + | |
| 33 | +## Metrics (`qlty metrics`) | |
| 34 | + | |
| 35 | +| metric | total | vs previous | | |
| 36 | +|---|---|---| | |
| 37 | +| funcs | 1549 | +13 | | |
| 38 | +| classes | 209 | +3 | | |
| 39 | +| fields | 623 | +9 | | |
| 40 | +| cyclo | 5441 | +125 | | |
| 41 | +| complex | 2474 | +68 | | |
| 42 | +| lcom | 0 | ±0 | | |
| 43 | +| lines | 26044 | +394 | | |
| 44 | +| loc | 15984 | +255 | | |
| 45 | + | |
| 46 | +### Most complex files | |
| 47 | + | |
| 48 | +| file | complex | cyclo | loc | | |
| 49 | +|---|---|---|---| | |
| 50 | +| editor/view.go | 48 | 124 | 380 | | |
| 51 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 52 | +| app/agents.go | 45 | 94 | 249 | | |
| 53 | +| app/app.go | 44 | 125 | 392 | | |
| 54 | +| theme/load.go | 44 | 71 | 214 | | |
| 55 | +| acp/session.go | 39 | 88 | 418 | | |
| 56 | +| acp/picker.go | 38 | 83 | 203 | | |
| 57 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 58 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 59 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 60 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 61 | +| acp/paste.go | 37 | 53 | 135 | | |
| 62 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 63 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 64 | +| ui/window.go | 35 | 110 | 251 | | |
| 65 | + | |
| 66 | +## Trend | |
| 67 | + | |
| 68 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 69 | +|---|---|---|---|---|---|---| | |
| 70 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | |
| 71 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 72 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 73 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 74 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 75 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 76 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 77 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 78 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 79 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +# Quality report — 2026-09-20T07:01:34Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ❌ **FAIL** | ||
| 4 | +- **Commit**: `3561e52` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #35 (previous: 2026-09-20T04:09:35Z) | ||
| 7 | + | ||
| 8 | +## Gate violations | ||
| 9 | + | ||
| 10 | +- code smells: 2 (max 0) | ||
| 11 | + | ||
| 12 | +## Lint issues (`qlty check`) | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Top rules | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +### Most affected files | ||
| 21 | + | ||
| 22 | +_none_ | ||
| 23 | + | ||
| 24 | +## Code smells (`qlty smells`) | ||
| 25 | + | ||
| 26 | +Total: **2** (vs previous: +2) | ||
| 27 | + | ||
| 28 | +| smell | file | line | detail | | ||
| 29 | +|---|---|---|---| | ||
| 30 | +| qlty:return-statements | configrepo/configrepo.go | 166 | Function with many returns (count = 8): loadFrom | | ||
| 31 | +| qlty:return-statements | configrepo/copy.go | 21 | Function with many returns (count = 8): copyTree | | ||
| 32 | + | ||
| 33 | +## Metrics (`qlty metrics`) | ||
| 34 | + | ||
| 35 | +| metric | total | vs previous | | ||
| 36 | +|---|---|---| | ||
| 37 | +| funcs | 1549 | +13 | | ||
| 38 | +| classes | 209 | +3 | | ||
| 39 | +| fields | 623 | +9 | | ||
| 40 | +| cyclo | 5441 | +125 | | ||
| 41 | +| complex | 2474 | +68 | | ||
| 42 | +| lcom | 0 | ±0 | | ||
| 43 | +| lines | 26044 | +394 | | ||
| 44 | +| loc | 15984 | +255 | | ||
| 45 | + | ||
| 46 | +### Most complex files | ||
| 47 | + | ||
| 48 | +| file | complex | cyclo | loc | | ||
| 49 | +|---|---|---|---| | ||
| 50 | +| editor/view.go | 48 | 124 | 380 | | ||
| 51 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 52 | +| app/agents.go | 45 | 94 | 249 | | ||
| 53 | +| app/app.go | 44 | 125 | 392 | | ||
| 54 | +| theme/load.go | 44 | 71 | 214 | | ||
| 55 | +| acp/session.go | 39 | 88 | 418 | | ||
| 56 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 57 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 58 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 59 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 60 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 61 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 62 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 63 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 64 | +| ui/window.go | 35 | 110 | 251 | | ||
| 65 | + | ||
| 66 | +## Trend | ||
| 67 | + | ||
| 68 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 69 | +|---|---|---|---|---|---|---| | ||
| 70 | +| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | ||
| 71 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 72 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 73 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 74 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 75 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 76 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 77 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 78 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 79 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
added
.quality/report-20260920T070254Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-20T07:02:54Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #36 (previous: 2026-09-20T07:01:34Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: -2) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1553 | +4 | | |
| 31 | +| classes | 209 | ±0 | | |
| 32 | +| fields | 623 | ±0 | | |
| 33 | +| cyclo | 5447 | +6 | | |
| 34 | +| complex | 2474 | ±0 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 26074 | +30 | | |
| 37 | +| loc | 16003 | +19 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| app/agents.go | 45 | 94 | 249 | | |
| 46 | +| app/app.go | 44 | 125 | 392 | | |
| 47 | +| theme/load.go | 44 | 71 | 214 | | |
| 48 | +| acp/session.go | 39 | 88 | 418 | | |
| 49 | +| acp/picker.go | 38 | 83 | 203 | | |
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 54 | +| acp/paste.go | 37 | 53 | 135 | | |
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 57 | +| ui/window.go | 35 | 110 | 251 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 64 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 65 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 66 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 67 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 68 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 69 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 70 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 71 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| 72 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-20T07:02:54Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `3561e52` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #36 (previous: 2026-09-20T07:01:34Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: -2) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1553 | +4 | | ||
| 31 | +| classes | 209 | ±0 | | ||
| 32 | +| fields | 623 | ±0 | | ||
| 33 | +| cyclo | 5447 | +6 | | ||
| 34 | +| complex | 2474 | ±0 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 26074 | +30 | | ||
| 37 | +| loc | 16003 | +19 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| app/agents.go | 45 | 94 | 249 | | ||
| 46 | +| app/app.go | 44 | 125 | 392 | | ||
| 47 | +| theme/load.go | 44 | 71 | 214 | | ||
| 48 | +| acp/session.go | 39 | 88 | 418 | | ||
| 49 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 54 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 57 | +| ui/window.go | 35 | 110 | 251 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 64 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 65 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 66 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 67 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 68 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 69 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 70 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 71 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
| 72 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | ||
modified
.quality/report-latest.md +24 -24 | @@ -1,9 +1,9 @@ | ||
| 1 | -# Quality report — 2026-09-18T19:50:22Z | |
| 1 | +# Quality report — 2026-09-20T07:02:54Z | |
| 2 | 2 | |
| 3 | 3 | - **Gate**: ✅ **PASS** |
| 4 | -- **Commit**: `c845294` on `main` | |
| 4 | +- **Commit**: `3561e52` on `main` | |
| 5 | 5 | - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) |
| 6 | -- **Run**: #30 (previous: 2026-09-17T04:42:34Z) | |
| 6 | +- **Run**: #36 (previous: 2026-09-20T07:01:34Z) | |
| 7 | 7 | |
| 8 | 8 | ## Lint issues (`qlty check`) |
| 9 | 9 | |
| @@ -19,7 +19,7 @@ _none_ | ||
| 19 | 19 | |
| 20 | 20 | ## Code smells (`qlty smells`) |
| 21 | 21 | |
| 22 | -Total: **0** (vs previous: ±0) | |
| 22 | +Total: **0** (vs previous: -2) | |
| 23 | 23 | |
| 24 | 24 | _none_ |
| 25 | 25 | |
| @@ -27,46 +27,46 @@ _none_ | ||
| 27 | 27 | |
| 28 | 28 | | metric | total | vs previous | |
| 29 | 29 | |---|---|---| |
| 30 | -| funcs | 1501 | +2 | | |
| 31 | -| classes | 199 | ±0 | | |
| 32 | -| fields | 594 | ±0 | | |
| 33 | -| cyclo | 5170 | +5 | | |
| 34 | -| complex | 2308 | +2 | | |
| 30 | +| funcs | 1553 | +4 | | |
| 31 | +| classes | 209 | ±0 | | |
| 32 | +| fields | 623 | ±0 | | |
| 33 | +| cyclo | 5447 | +6 | | |
| 34 | +| complex | 2474 | ±0 | | |
| 35 | 35 | | lcom | 0 | ±0 | |
| 36 | -| lines | 24873 | +40 | | |
| 37 | -| loc | 15295 | +14 | | |
| 36 | +| lines | 26074 | +30 | | |
| 37 | +| loc | 16003 | +19 | | |
| 38 | 38 | |
| 39 | 39 | ### Most complex files |
| 40 | 40 | |
| 41 | 41 | | file | complex | cyclo | loc | |
| 42 | 42 | |---|---|---|---| |
| 43 | 43 | | editor/view.go | 48 | 124 | 380 | |
| 44 | -| app/actions_file.go | 46 | 74 | 238 | | |
| 45 | -| app/agents.go | 45 | 94 | 248 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| app/agents.go | 45 | 94 | 249 | | |
| 46 | +| app/app.go | 44 | 125 | 392 | | |
| 46 | 47 | | theme/load.go | 44 | 71 | 214 | |
| 47 | -| app/app.go | 43 | 123 | 375 | | |
| 48 | -| acp/session.go | 39 | 88 | 414 | | |
| 48 | +| acp/session.go | 39 | 88 | 418 | | |
| 49 | 49 | | acp/picker.go | 38 | 83 | 203 | |
| 50 | 50 | | acp/transcript.go | 38 | 89 | 231 | |
| 51 | 51 | | syntax/toml.go | 38 | 92 | 179 | |
| 52 | 52 | | ui/dialog.go | 38 | 71 | 197 | |
| 53 | 53 | | ui/menu_events.go | 38 | 66 | 145 | |
| 54 | -| app/toolchain.go | 35 | 72 | 242 | | |
| 54 | +| acp/paste.go | 37 | 53 | 135 | | |
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 55 | 57 | | ui/window.go | 35 | 110 | 251 | |
| 56 | -| acp/config.go | 34 | 43 | 149 | | |
| 57 | -| snippets/snippets.go | 34 | 39 | 135 | | |
| 58 | 58 | |
| 59 | 59 | ## Trend |
| 60 | 60 | |
| 61 | 61 | | run | timestamp | error | warning | smells | complex | gate | |
| 62 | 62 | |---|---|---|---|---|---|---| |
| 63 | -| 21 | 2026-09-15T09:34:09Z | 0 | 0 | 3 | 2119 | FAIL | | |
| 64 | -| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS | | |
| 65 | -| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL | | |
| 66 | -| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | |
| 67 | -| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | |
| 68 | -| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | |
| 69 | 63 | | 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | |
| 70 | 64 | | 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | |
| 71 | 65 | | 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | |
| 72 | 66 | | 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | |
| 67 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 68 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 69 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 70 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 71 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| 72 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | |
| @@ -1,9 +1,9 @@ | |||
| 1 | -# Quality report — 2026-09-18T19:50:22Z | 1 | +# Quality report — 2026-09-20T07:02:54Z |
| 2 | 2 | ||
| 3 | - **Gate**: ✅ **PASS** | 3 | - **Gate**: ✅ **PASS** |
| 4 | -- **Commit**: `c845294` on `main` | 4 | +- **Commit**: `3561e52` on `main` |
| 5 | - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | 5 | - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) |
| 6 | -- **Run**: #30 (previous: 2026-09-17T04:42:34Z) | 6 | +- **Run**: #36 (previous: 2026-09-20T07:01:34Z) |
| 7 | 7 | ||
| 8 | ## Lint issues (`qlty check`) | 8 | ## Lint issues (`qlty check`) |
| 9 | 9 | ||
| @@ -19,7 +19,7 @@ _none_ | |||
| 19 | 19 | ||
| 20 | ## Code smells (`qlty smells`) | 20 | ## Code smells (`qlty smells`) |
| 21 | 21 | ||
| 22 | -Total: **0** (vs previous: ±0) | 22 | +Total: **0** (vs previous: -2) |
| 23 | 23 | ||
| 24 | _none_ | 24 | _none_ |
| 25 | 25 | ||
| @@ -27,46 +27,46 @@ _none_ | |||
| 27 | 27 | ||
| 28 | | metric | total | vs previous | | 28 | | metric | total | vs previous | |
| 29 | |---|---|---| | 29 | |---|---|---| |
| 30 | -| funcs | 1501 | +2 | | 30 | +| funcs | 1553 | +4 | |
| 31 | -| classes | 199 | ±0 | | 31 | +| classes | 209 | ±0 | |
| 32 | -| fields | 594 | ±0 | | 32 | +| fields | 623 | ±0 | |
| 33 | -| cyclo | 5170 | +5 | | 33 | +| cyclo | 5447 | +6 | |
| 34 | -| complex | 2308 | +2 | | 34 | +| complex | 2474 | ±0 | |
| 35 | | lcom | 0 | ±0 | | 35 | | lcom | 0 | ±0 | |
| 36 | -| lines | 24873 | +40 | | 36 | +| lines | 26074 | +30 | |
| 37 | -| loc | 15295 | +14 | | 37 | +| loc | 16003 | +19 | |
| 38 | 38 | ||
| 39 | ### Most complex files | 39 | ### Most complex files |
| 40 | 40 | ||
| 41 | | file | complex | cyclo | loc | | 41 | | file | complex | cyclo | loc | |
| 42 | |---|---|---|---| | 42 | |---|---|---|---| |
| 43 | | editor/view.go | 48 | 124 | 380 | | 43 | | editor/view.go | 48 | 124 | 380 | |
| 44 | -| app/actions_file.go | 46 | 74 | 238 | | 44 | +| app/actions_file.go | 47 | 72 | 246 | |
| 45 | -| app/agents.go | 45 | 94 | 248 | | 45 | +| app/agents.go | 45 | 94 | 249 | |
| 46 | +| app/app.go | 44 | 125 | 392 | | ||
| 46 | | theme/load.go | 44 | 71 | 214 | | 47 | | theme/load.go | 44 | 71 | 214 | |
| 47 | -| app/app.go | 43 | 123 | 375 | | 48 | +| acp/session.go | 39 | 88 | 418 | |
| 48 | -| acp/session.go | 39 | 88 | 414 | | ||
| 49 | | acp/picker.go | 38 | 83 | 203 | | 49 | | acp/picker.go | 38 | 83 | 203 | |
| 50 | | acp/transcript.go | 38 | 89 | 231 | | 50 | | acp/transcript.go | 38 | 89 | 231 | |
| 51 | | syntax/toml.go | 38 | 92 | 179 | | 51 | | syntax/toml.go | 38 | 92 | 179 | |
| 52 | | ui/dialog.go | 38 | 71 | 197 | | 52 | | ui/dialog.go | 38 | 71 | 197 | |
| 53 | | ui/menu_events.go | 38 | 66 | 145 | | 53 | | ui/menu_events.go | 38 | 66 | 145 | |
| 54 | -| app/toolchain.go | 35 | 72 | 242 | | 54 | +| acp/paste.go | 37 | 53 | 135 | |
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 55 | | ui/window.go | 35 | 110 | 251 | | 57 | | ui/window.go | 35 | 110 | 251 | |
| 56 | -| acp/config.go | 34 | 43 | 149 | | ||
| 57 | -| snippets/snippets.go | 34 | 39 | 135 | | ||
| 58 | 58 | ||
| 59 | ## Trend | 59 | ## Trend |
| 60 | 60 | ||
| 61 | | run | timestamp | error | warning | smells | complex | gate | | 61 | | run | timestamp | error | warning | smells | complex | gate | |
| 62 | |---|---|---|---|---|---|---| | 62 | |---|---|---|---|---|---|---| |
| 63 | -| 21 | 2026-09-15T09:34:09Z | 0 | 0 | 3 | 2119 | FAIL | | ||
| 64 | -| 22 | 2026-09-15T09:34:55Z | 0 | 0 | 0 | 2119 | PASS | | ||
| 65 | -| 23 | 2026-09-15T16:57:07Z | 0 | 0 | 2 | 2157 | FAIL | | ||
| 66 | -| 24 | 2026-09-15T16:57:58Z | 0 | 0 | 0 | 2156 | PASS | | ||
| 67 | -| 25 | 2026-09-15T23:50:29Z | 0 | 0 | 4 | 2254 | FAIL | | ||
| 68 | -| 26 | 2026-09-15T23:51:24Z | 0 | 0 | 0 | 2257 | PASS | | ||
| 69 | | 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | 63 | | 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | |
| 70 | | 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | 64 | | 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | |
| 71 | | 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | 65 | | 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | |
| 72 | | 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | 66 | | 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | |
| 67 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 68 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 69 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 70 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 71 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
| 72 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | ||
modified
README.md +1 -0 | @@ -52,6 +52,7 @@ Sixteen packages. Dependencies run strictly downwards, with no cycles and no int | ||
| 52 | 52 | | `profile` | The editor's identity: name, slug, language server, starter templates | |
| 53 | 53 | | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search | |
| 54 | 54 | | `projectfile` | Atomic writes of a project's own TOML files | |
| 55 | +| `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone | | |
| 55 | 56 | | `version` | What build of itself a binary is | |
| 56 | 57 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | |
| 57 | 58 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own | |
| @@ -52,6 +52,7 @@ Sixteen packages. Dependencies run strictly downwards, with no cycles and no int | |||
| 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 | +| `configrepo` | A shared `.turbo-<slug>` directory fetched from a repository URL — rickub, GitHub, GitLab, Codeberg — by a shallow git clone | | ||
| 55 | | `version` | What build of itself a binary is | | 56 | | `version` | What build of itself a binary is | |
| 56 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | | 57 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | |
| 57 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own | | 58 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own | |
modified
acp/README.md +5 -0 | @@ -18,6 +18,7 @@ The editor holds no API key, knows no provider, and implements no tool-calling l | ||
| 18 | 18 | | `transcript.go` | The conversation as a **value** — no terminal, no colours | |
| 19 | 19 | | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text | |
| 20 | 20 | | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` | |
| 21 | +| `paste.go` | A paste kept aside: the `[Pasted #1 · 6 lines · 700 chars]` token in the box, and the text the agent gets for it | | |
| 21 | 22 | | `render.go` | Entries into drawn lines: fences, wrapping, styles | |
| 22 | 23 | | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below | |
| 23 | 24 | | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files | |
| @@ -36,6 +37,10 @@ The editor holds no API key, knows no provider, and implements no tool-calling l | ||
| 36 | 37 | |
| 37 | 38 | **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature. |
| 38 | 39 | |
| 40 | +**The box wraps what you type; the lines are still yours.** `input` holds the lines Alt-Enter made, and `input.go` lays them out as rows at the pane's width on every frame and every arrow key — nothing is stored, so a resize cannot leave the two apart. A row breaks after the last space that fits, keeping that space at the end of the row, so the runes of a line are exactly its rows laid end to end and every cursor position belongs to one row. A row is `W-3` runes wide — prompt, space, and one column kept free so the cursor just past a full row is inside the pane; before this, `TextLimited` cut the line off with `…` and `ShowCursor` hid a cursor that had reached the frame. `↑`/`↓` move by row, not by line: a paragraph wrapped three times is three rows to the eye. The rows shown end with the cursor's, so a prompt taller than the box scrolls under the rule, and the `>` marker goes with the first row rather than standing beside whatever is on top. | |
| 41 | + | |
| 42 | +**A long paste is a token in the box and its text on the wire.** `View.Paste` takes what was pasted — from the terminal, bracketed and handed over whole by `app`, or from the editor's clipboard through `OnPaste` on Ctrl-V / Shift-Ins / Edit ▸ Paste — and either types it (one line, up to `pasteInlineLimit` runes: a path, a command) or keeps it in `pastes` and inserts `[Pasted #N · L lines · C chars]` where the cursor is. `Send` calls `Session.PromptWith(text, pastesIn(text), mentions…)`: the transcript keeps the text with the tokens in it, so the exchange stays readable, and `expandPastes` replaces each token by its text **inside the text blocks only, after `blocksFor` has cut the mentions out** — a paste goes byte for byte, so an `@name` inside a pasted log is characters, not a file. A token whose text was deleted with it is simply absent from `pastesIn`. Tokens are found in a line by their text (`tokensIn`), never remembered by position; Backspace after one and Delete before one remove it whole, `←`/`→` step over it, and a move by row that lands inside one is pushed to the nearer edge (`leaveToken`) — a half token stands for nothing. It is drawn in the type style, as a tool call is: a thing the agent will be handed, not words. Numbers run on for the life of the window, so `#1` in an earlier exchange is not `#1` in this one. | |
| 43 | + | |
| 39 | 44 | **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice. |
| 40 | 45 | |
| 41 | 46 | **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly. |
| @@ -18,6 +18,7 @@ The editor holds no API key, knows no provider, and implements no tool-calling l | |||
| 18 | | `transcript.go` | The conversation as a **value** — no terminal, no colours | | 18 | | `transcript.go` | The conversation as a **value** — no terminal, no colours | |
| 19 | | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text | | 19 | | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text | |
| 20 | | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` | | 20 | | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` | |
| 21 | +| `paste.go` | A paste kept aside: the `[Pasted #1 · 6 lines · 700 chars]` token in the box, and the text the agent gets for it | | ||
| 21 | | `render.go` | Entries into drawn lines: fences, wrapping, styles | | 22 | | `render.go` | Entries into drawn lines: fences, wrapping, styles | |
| 22 | | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below | | 23 | | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below | |
| 23 | | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files | | 24 | | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files | |
| @@ -36,6 +37,10 @@ The editor holds no API key, knows no provider, and implements no tool-calling l | |||
| 36 | 37 | ||
| 37 | **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature. | 38 | **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature. |
| 38 | 39 | ||
| 40 | +**The box wraps what you type; the lines are still yours.** `input` holds the lines Alt-Enter made, and `input.go` lays them out as rows at the pane's width on every frame and every arrow key — nothing is stored, so a resize cannot leave the two apart. A row breaks after the last space that fits, keeping that space at the end of the row, so the runes of a line are exactly its rows laid end to end and every cursor position belongs to one row. A row is `W-3` runes wide — prompt, space, and one column kept free so the cursor just past a full row is inside the pane; before this, `TextLimited` cut the line off with `…` and `ShowCursor` hid a cursor that had reached the frame. `↑`/`↓` move by row, not by line: a paragraph wrapped three times is three rows to the eye. The rows shown end with the cursor's, so a prompt taller than the box scrolls under the rule, and the `>` marker goes with the first row rather than standing beside whatever is on top. | ||
| 41 | + | ||
| 42 | +**A long paste is a token in the box and its text on the wire.** `View.Paste` takes what was pasted — from the terminal, bracketed and handed over whole by `app`, or from the editor's clipboard through `OnPaste` on Ctrl-V / Shift-Ins / Edit ▸ Paste — and either types it (one line, up to `pasteInlineLimit` runes: a path, a command) or keeps it in `pastes` and inserts `[Pasted #N · L lines · C chars]` where the cursor is. `Send` calls `Session.PromptWith(text, pastesIn(text), mentions…)`: the transcript keeps the text with the tokens in it, so the exchange stays readable, and `expandPastes` replaces each token by its text **inside the text blocks only, after `blocksFor` has cut the mentions out** — a paste goes byte for byte, so an `@name` inside a pasted log is characters, not a file. A token whose text was deleted with it is simply absent from `pastesIn`. Tokens are found in a line by their text (`tokensIn`), never remembered by position; Backspace after one and Delete before one remove it whole, `←`/`→` step over it, and a move by row that lands inside one is pushed to the nearer edge (`leaveToken`) — a half token stands for nothing. It is drawn in the type style, as a tool call is: a thing the agent will be handed, not words. Numbers run on for the life of the window, so `#1` in an earlier exchange is not `#1` in this one. | ||
| 43 | + | ||
| 39 | **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice. | 44 | **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice. |
| 40 | 45 | ||
| 41 | **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly. | 46 | **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly. |
added
acp/input.go +111 -0 | new file mode 100644 | ||
| @@ -0,0 +1,111 @@ | ||
| 1 | +// Laying the box you type in out as rows: a long line wraps at spaces rather | |
| 2 | +// than running off the edge and taking the cursor with it. | |
| 3 | + | |
| 4 | +package acp | |
| 5 | + | |
| 6 | +// inputRow is one row of the box as drawn: which typed line it shows, and | |
| 7 | +// which of that line's runes, as a half-open range. | |
| 8 | +// | |
| 9 | +// The typed lines stay the model — Alt-Enter makes one, Backspace at a line | |
| 10 | +// start joins two — and wrapping is only how they are shown. A row is never | |
| 11 | +// stored: it is recomputed from the lines and the width at every frame and | |
| 12 | +// every key, so a resize cannot leave the two out of step. | |
| 13 | +type inputRow struct { | |
| 14 | + line int | |
| 15 | + start, end int | |
| 16 | +} | |
| 17 | + | |
| 18 | +// inputWidth is how many runes a row of the box holds: the prompt, a space, | |
| 19 | +// and one column kept free so the cursor sitting just past a full row is | |
| 20 | +// still inside the pane rather than on the frame. | |
| 21 | +func (v *View) inputWidth() int { | |
| 22 | + return max(v.Bounds().W-3, 1) | |
| 23 | +} | |
| 24 | + | |
| 25 | +// inputRows lays every typed line out at a width. | |
| 26 | +func (v *View) inputRows(width int) []inputRow { | |
| 27 | + var rows []inputRow | |
| 28 | + for line, text := range v.input { | |
| 29 | + rows = append(rows, wrapInputLine(line, []rune(text), width)...) | |
| 30 | + } | |
| 31 | + return rows | |
| 32 | +} | |
| 33 | + | |
| 34 | +// wrapInputLine breaks one typed line into rows no wider than width, at the | |
| 35 | +// last space that fits and mid-word when a word is longer than a row. | |
| 36 | +// | |
| 37 | +// The space a row breaks at stays at the end of that row rather than opening | |
| 38 | +// the next, so the runes of the line are exactly the rows laid end to end and | |
| 39 | +// a cursor position is one row's and no other's. A row may therefore hold | |
| 40 | +// width+1 runes, the last a space, which is not drawn. | |
| 41 | +// | |
| 42 | +// A line that ends in such a space — you have just typed it — is followed by | |
| 43 | +// an empty row, which falls out of the loop: what is left after the break is | |
| 44 | +// nothing, and nothing is a row. That is where the cursor then sits. | |
| 45 | +func wrapInputLine(line int, runes []rune, width int) []inputRow { | |
| 46 | + var rows []inputRow | |
| 47 | + start := 0 | |
| 48 | + for { | |
| 49 | + remaining := runes[start:] | |
| 50 | + if len(remaining) <= width { | |
| 51 | + return append(rows, inputRow{line: line, start: start, end: len(runes)}) | |
| 52 | + } | |
| 53 | + at := start + inputBreak(remaining, width) | |
| 54 | + rows = append(rows, inputRow{line: line, start: start, end: at}) | |
| 55 | + start = at | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +// inputBreak returns how many runes the first row of a run longer than width | |
| 60 | +// takes: through the last space in the first width+1 runes, or width when | |
| 61 | +// there is none. | |
| 62 | +func inputBreak(runes []rune, width int) int { | |
| 63 | + for at := width; at >= 0; at-- { | |
| 64 | + if runes[at] == ' ' { | |
| 65 | + return at + 1 | |
| 66 | + } | |
| 67 | + } | |
| 68 | + return width | |
| 69 | +} | |
| 70 | + | |
| 71 | +// inputCursor returns which row the cursor is on and how far along it. | |
| 72 | +// | |
| 73 | +// A position at the very end of a row that continues belongs to the next | |
| 74 | +// row's start, which is what makes typing across a wrap look like typing. | |
| 75 | +func (v *View) inputCursor(rows []inputRow) (row, column int) { | |
| 76 | + for i, r := range rows { | |
| 77 | + if r.line != v.cursor { | |
| 78 | + continue | |
| 79 | + } | |
| 80 | + last := i == len(rows)-1 || rows[i+1].line != r.line | |
| 81 | + if v.column < r.end || (last && v.column <= r.end) { | |
| 82 | + return i, v.column - r.start | |
| 83 | + } | |
| 84 | + } | |
| 85 | + return max(len(rows)-1, 0), 0 | |
| 86 | +} | |
| 87 | + | |
| 88 | +// moveInputRow moves the cursor to the row above or below the one it is on, | |
| 89 | +// keeping its column where the row allows, and reports whether there was one. | |
| 90 | +// | |
| 91 | +// Rows, not lines: a paragraph that wraps three times is three rows to the | |
| 92 | +// eye, and Up from its last row should land on its middle, not on the | |
| 93 | +// paragraph before. | |
| 94 | +func (v *View) moveInputRow(by int) bool { | |
| 95 | + rows := v.inputRows(v.inputWidth()) | |
| 96 | + row, column := v.inputCursor(rows) | |
| 97 | + target := row + by | |
| 98 | + if target < 0 || target >= len(rows) { | |
| 99 | + return false | |
| 100 | + } | |
| 101 | + | |
| 102 | + r := rows[target] | |
| 103 | + widest := r.end - r.start | |
| 104 | + if last := target == len(rows)-1 || rows[target+1].line != r.line; !last { | |
| 105 | + widest-- // the row's last position is the next row's first | |
| 106 | + } | |
| 107 | + v.cursor = r.line | |
| 108 | + v.column = r.start + min(column, max(widest, 0)) | |
| 109 | + v.leaveToken() | |
| 110 | + return true | |
| 111 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,111 @@ | |||
| 1 | +// Laying the box you type in out as rows: a long line wraps at spaces rather | ||
| 2 | +// than running off the edge and taking the cursor with it. | ||
| 3 | + | ||
| 4 | +package acp | ||
| 5 | + | ||
| 6 | +// inputRow is one row of the box as drawn: which typed line it shows, and | ||
| 7 | +// which of that line's runes, as a half-open range. | ||
| 8 | +// | ||
| 9 | +// The typed lines stay the model — Alt-Enter makes one, Backspace at a line | ||
| 10 | +// start joins two — and wrapping is only how they are shown. A row is never | ||
| 11 | +// stored: it is recomputed from the lines and the width at every frame and | ||
| 12 | +// every key, so a resize cannot leave the two out of step. | ||
| 13 | +type inputRow struct { | ||
| 14 | + line int | ||
| 15 | + start, end int | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +// inputWidth is how many runes a row of the box holds: the prompt, a space, | ||
| 19 | +// and one column kept free so the cursor sitting just past a full row is | ||
| 20 | +// still inside the pane rather than on the frame. | ||
| 21 | +func (v *View) inputWidth() int { | ||
| 22 | + return max(v.Bounds().W-3, 1) | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +// inputRows lays every typed line out at a width. | ||
| 26 | +func (v *View) inputRows(width int) []inputRow { | ||
| 27 | + var rows []inputRow | ||
| 28 | + for line, text := range v.input { | ||
| 29 | + rows = append(rows, wrapInputLine(line, []rune(text), width)...) | ||
| 30 | + } | ||
| 31 | + return rows | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +// wrapInputLine breaks one typed line into rows no wider than width, at the | ||
| 35 | +// last space that fits and mid-word when a word is longer than a row. | ||
| 36 | +// | ||
| 37 | +// The space a row breaks at stays at the end of that row rather than opening | ||
| 38 | +// the next, so the runes of the line are exactly the rows laid end to end and | ||
| 39 | +// a cursor position is one row's and no other's. A row may therefore hold | ||
| 40 | +// width+1 runes, the last a space, which is not drawn. | ||
| 41 | +// | ||
| 42 | +// A line that ends in such a space — you have just typed it — is followed by | ||
| 43 | +// an empty row, which falls out of the loop: what is left after the break is | ||
| 44 | +// nothing, and nothing is a row. That is where the cursor then sits. | ||
| 45 | +func wrapInputLine(line int, runes []rune, width int) []inputRow { | ||
| 46 | + var rows []inputRow | ||
| 47 | + start := 0 | ||
| 48 | + for { | ||
| 49 | + remaining := runes[start:] | ||
| 50 | + if len(remaining) <= width { | ||
| 51 | + return append(rows, inputRow{line: line, start: start, end: len(runes)}) | ||
| 52 | + } | ||
| 53 | + at := start + inputBreak(remaining, width) | ||
| 54 | + rows = append(rows, inputRow{line: line, start: start, end: at}) | ||
| 55 | + start = at | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// inputBreak returns how many runes the first row of a run longer than width | ||
| 60 | +// takes: through the last space in the first width+1 runes, or width when | ||
| 61 | +// there is none. | ||
| 62 | +func inputBreak(runes []rune, width int) int { | ||
| 63 | + for at := width; at >= 0; at-- { | ||
| 64 | + if runes[at] == ' ' { | ||
| 65 | + return at + 1 | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | + return width | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// inputCursor returns which row the cursor is on and how far along it. | ||
| 72 | +// | ||
| 73 | +// A position at the very end of a row that continues belongs to the next | ||
| 74 | +// row's start, which is what makes typing across a wrap look like typing. | ||
| 75 | +func (v *View) inputCursor(rows []inputRow) (row, column int) { | ||
| 76 | + for i, r := range rows { | ||
| 77 | + if r.line != v.cursor { | ||
| 78 | + continue | ||
| 79 | + } | ||
| 80 | + last := i == len(rows)-1 || rows[i+1].line != r.line | ||
| 81 | + if v.column < r.end || (last && v.column <= r.end) { | ||
| 82 | + return i, v.column - r.start | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + return max(len(rows)-1, 0), 0 | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// moveInputRow moves the cursor to the row above or below the one it is on, | ||
| 89 | +// keeping its column where the row allows, and reports whether there was one. | ||
| 90 | +// | ||
| 91 | +// Rows, not lines: a paragraph that wraps three times is three rows to the | ||
| 92 | +// eye, and Up from its last row should land on its middle, not on the | ||
| 93 | +// paragraph before. | ||
| 94 | +func (v *View) moveInputRow(by int) bool { | ||
| 95 | + rows := v.inputRows(v.inputWidth()) | ||
| 96 | + row, column := v.inputCursor(rows) | ||
| 97 | + target := row + by | ||
| 98 | + if target < 0 || target >= len(rows) { | ||
| 99 | + return false | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + r := rows[target] | ||
| 103 | + widest := r.end - r.start | ||
| 104 | + if last := target == len(rows)-1 || rows[target+1].line != r.line; !last { | ||
| 105 | + widest-- // the row's last position is the next row's first | ||
| 106 | + } | ||
| 107 | + v.cursor = r.line | ||
| 108 | + v.column = r.start + min(column, max(widest, 0)) | ||
| 109 | + v.leaveToken() | ||
| 110 | + return true | ||
| 111 | +} | ||
added
acp/input_test.go +194 -0 | new file mode 100644 | ||
| @@ -0,0 +1,194 @@ | ||
| 1 | +package acp_test | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "github.com/gdamore/tcell/v2" | |
| 8 | + | |
| 9 | + "rickub.com/turbo-editors/turbo-core/acp" | |
| 10 | + "rickub.com/turbo-editors/turbo-core/theme" | |
| 11 | + "rickub.com/turbo-editors/turbo-core/ui" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// drawWithCursor paints a focused view and returns the rows and where the | |
| 15 | +// terminal's cursor was put — or -1, -1 when it was hidden, which is what | |
| 16 | +// happened to a cursor that ran off the edge of the box. | |
| 17 | +func drawWithCursor(t *testing.T, view *acp.View, width, height int) (rows []string, x, y int) { | |
| 18 | + t.Helper() | |
| 19 | + | |
| 20 | + screen := tcell.NewSimulationScreen("UTF-8") | |
| 21 | + if err := screen.Init(); err != nil { | |
| 22 | + t.Fatalf("starting the screen: %v", err) | |
| 23 | + } | |
| 24 | + defer screen.Fini() | |
| 25 | + screen.SetSize(width, height) | |
| 26 | + | |
| 27 | + th, err := theme.Load(theme.DefaultName, "") | |
| 28 | + if err != nil { | |
| 29 | + t.Fatalf("loading the theme: %v", err) | |
| 30 | + } | |
| 31 | + | |
| 32 | + view.SetFocused(true) | |
| 33 | + view.Draw(ui.NewPainter(screen), th) | |
| 34 | + screen.Show() | |
| 35 | + | |
| 36 | + cells, w, h := screen.GetContents() | |
| 37 | + rows = make([]string, h) | |
| 38 | + for row := range h { | |
| 39 | + var text strings.Builder | |
| 40 | + for col := range w { | |
| 41 | + text.WriteString(string(cells[row*w+col].Runes)) | |
| 42 | + } | |
| 43 | + rows[row] = strings.TrimRight(text.String(), " ") | |
| 44 | + } | |
| 45 | + | |
| 46 | + x, y, visible := screen.GetCursor() | |
| 47 | + if !visible { | |
| 48 | + return rows, -1, -1 | |
| 49 | + } | |
| 50 | + return rows, x, y | |
| 51 | +} | |
| 52 | + | |
| 53 | +// inputRows returns the rows of the box you type in: everything below the | |
| 54 | +// rule. | |
| 55 | +func inputRows(rows []string) []string { | |
| 56 | + for i, row := range rows { | |
| 57 | + if strings.Contains(row, "─") { | |
| 58 | + return rows[i+1:] | |
| 59 | + } | |
| 60 | + } | |
| 61 | + return nil | |
| 62 | +} | |
| 63 | + | |
| 64 | +func typeInto(view *acp.View, text string) { | |
| 65 | + for _, r := range text { | |
| 66 | + view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +func TestALongPromptWrapsAtSpacesInsideTheBox(t *testing.T) { | |
| 71 | + // This is the feature: before, the line was cut off with an ellipsis at | |
| 72 | + // the edge and the rest of what you typed was invisible. | |
| 73 | + view, _ := newView(t, 30, 12) | |
| 74 | + typeInto(view, "please explain how the wrapping of a long prompt works") | |
| 75 | + | |
| 76 | + rows, _, _ := drawWithCursor(t, view, 30, 12) | |
| 77 | + box := inputRows(rows) | |
| 78 | + | |
| 79 | + if !strings.HasPrefix(box[0], "> please explain how the") || box[1] != " wrapping of a long prompt" || box[2] != " works" { | |
| 80 | + t.Errorf("the box is not wrapped at spaces:\n%s", strings.Join(box, "\n")) | |
| 81 | + } | |
| 82 | + if strings.Contains(strings.Join(box, "\n"), "…") { | |
| 83 | + t.Errorf("the box still cuts the prompt off:\n%s", strings.Join(box, "\n")) | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +func TestTheCursorFollowsTheTextOntoTheWrappedRow(t *testing.T) { | |
| 88 | + view, _ := newView(t, 30, 12) | |
| 89 | + typeInto(view, "please explain how the wrapping") | |
| 90 | + | |
| 91 | + _, x, y := drawWithCursor(t, view, 30, 12) | |
| 92 | + | |
| 93 | + // Row 0 of the box is screen row 9 (12 rows: 8 of conversation, the rule, | |
| 94 | + // then 3 of box); "wrapping" is 8 runes on the second row, after "> " and | |
| 95 | + // its own indent. | |
| 96 | + if x != 2+len("wrapping") || y != 10 { | |
| 97 | + t.Errorf("cursor at (%d, %d), want it after %q on the second row (%d, %d)", x, y, "wrapping", 2+len("wrapping"), 10) | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +func TestTheCursorAtTheEndOfAFullRowStaysInsideThePane(t *testing.T) { | |
| 102 | + // A row holds width-3 runes: prompt, space, and a column for exactly | |
| 103 | + // this cursor. Without it the cursor sat on the frame and tcell hid it. | |
| 104 | + view, _ := newView(t, 30, 12) | |
| 105 | + typeInto(view, strings.Repeat("x", 27)) | |
| 106 | + | |
| 107 | + _, x, y := drawWithCursor(t, view, 30, 12) | |
| 108 | + | |
| 109 | + if x != 29 || y != 9 { | |
| 110 | + t.Errorf("cursor at (%d, %d), want (29, 9): the last column of the pane, first row of the box", x, y) | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +func TestAWordLongerThanTheBoxBreaksAtTheEdgeRatherThanVanishing(t *testing.T) { | |
| 115 | + view, _ := newView(t, 30, 12) | |
| 116 | + typeInto(view, strings.Repeat("a", 40)) | |
| 117 | + | |
| 118 | + rows, _, _ := drawWithCursor(t, view, 30, 12) | |
| 119 | + box := inputRows(rows) | |
| 120 | + | |
| 121 | + if box[0] != "> "+strings.Repeat("a", 27) || box[1] != " "+strings.Repeat("a", 13) { | |
| 122 | + t.Errorf("a long word is not broken at the edge:\n%s", strings.Join(box, "\n")) | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +func TestUpAndDownMoveByRowInsideAWrappedPrompt(t *testing.T) { | |
| 127 | + // Three rows to the eye are three rows to the arrow keys. Up from the last | |
| 128 | + // row lands on the middle one, not on the conversation. | |
| 129 | + view, _ := newView(t, 30, 12) | |
| 130 | + typeInto(view, "please explain how the wrapping of a long prompt works") | |
| 131 | + | |
| 132 | + if !view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) { | |
| 133 | + t.Fatal("Up was not taken by the box, though there is a row above the cursor") | |
| 134 | + } | |
| 135 | + _, x, y := drawWithCursor(t, view, 30, 12) | |
| 136 | + if y != 10 || x != 2+len("works") { | |
| 137 | + t.Errorf("after Up the cursor is at (%d, %d), want the middle row at the same column (%d, 10)", x, y, 2+len("works")) | |
| 138 | + } | |
| 139 | + | |
| 140 | + view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) | |
| 141 | + view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) | |
| 142 | + _, x, y = drawWithCursor(t, view, 30, 12) | |
| 143 | + if y != 10 || x != 2+len("works") { | |
| 144 | + t.Errorf("Up then Down did not come back: cursor at (%d, %d)", x, y) | |
| 145 | + } | |
| 146 | + | |
| 147 | + // The column is kept, not the word: five in on the middle row is between | |
| 148 | + // "wrapp" and "ing", which is where a key pressed now goes. | |
| 149 | + typeInto(view, "!") | |
| 150 | + if got := view.Input(); got != "please explain how the wrapp!ing of a long prompt works" { | |
| 151 | + t.Errorf("typing after the moves went to the wrong place: %q", got) | |
| 152 | + } | |
| 153 | +} | |
| 154 | + | |
| 155 | +func TestUpFromTheFirstRowOfTheBoxIsLeftForTheConversation(t *testing.T) { | |
| 156 | + view, _ := newView(t, 30, 12) | |
| 157 | + typeInto(view, "short") | |
| 158 | + | |
| 159 | + if view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) && view.Input() != "short" { | |
| 160 | + t.Error("Up on a one-row prompt changed the input") | |
| 161 | + } | |
| 162 | +} | |
| 163 | + | |
| 164 | +func TestAPromptTallerThanTheBoxScrollsToKeepTheCursorInSight(t *testing.T) { | |
| 165 | + // Three rows of box, five rows of prompt: the first two go up under the | |
| 166 | + // rule, and the prompt marker goes with them. | |
| 167 | + view, _ := newView(t, 30, 12) | |
| 168 | + typeInto(view, "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty") | |
| 169 | + | |
| 170 | + rows, _, y := drawWithCursor(t, view, 30, 12) | |
| 171 | + box := inputRows(rows) | |
| 172 | + | |
| 173 | + if y != 11 { | |
| 174 | + t.Errorf("the cursor is on screen row %d, want the last row of the box, 11", y) | |
| 175 | + } | |
| 176 | + if strings.HasPrefix(box[0], ">") { | |
| 177 | + t.Errorf("the prompt marker is beside a row that is not the first:\n%s", strings.Join(box, "\n")) | |
| 178 | + } | |
| 179 | + if !strings.HasSuffix(box[2], "twenty") { | |
| 180 | + t.Errorf("the last row does not end with what was typed last:\n%s", strings.Join(box, "\n")) | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +func TestATypedTrailingSpaceAtTheEdgePutsTheCursorOnTheNextRow(t *testing.T) { | |
| 185 | + // 27 runes fill a row; the space after them is the break, not drawn, and | |
| 186 | + // the cursor is at the start of an empty row below rather than on the frame. | |
| 187 | + view, _ := newView(t, 30, 12) | |
| 188 | + typeInto(view, strings.Repeat("x", 27)+" ") | |
| 189 | + | |
| 190 | + _, x, y := drawWithCursor(t, view, 30, 12) | |
| 191 | + if x != 2 || y != 10 { | |
| 192 | + t.Errorf("cursor at (%d, %d), want (2, 10)", x, y) | |
| 193 | + } | |
| 194 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,194 @@ | |||
| 1 | +package acp_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "github.com/gdamore/tcell/v2" | ||
| 8 | + | ||
| 9 | + "rickub.com/turbo-editors/turbo-core/acp" | ||
| 10 | + "rickub.com/turbo-editors/turbo-core/theme" | ||
| 11 | + "rickub.com/turbo-editors/turbo-core/ui" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// drawWithCursor paints a focused view and returns the rows and where the | ||
| 15 | +// terminal's cursor was put — or -1, -1 when it was hidden, which is what | ||
| 16 | +// happened to a cursor that ran off the edge of the box. | ||
| 17 | +func drawWithCursor(t *testing.T, view *acp.View, width, height int) (rows []string, x, y int) { | ||
| 18 | + t.Helper() | ||
| 19 | + | ||
| 20 | + screen := tcell.NewSimulationScreen("UTF-8") | ||
| 21 | + if err := screen.Init(); err != nil { | ||
| 22 | + t.Fatalf("starting the screen: %v", err) | ||
| 23 | + } | ||
| 24 | + defer screen.Fini() | ||
| 25 | + screen.SetSize(width, height) | ||
| 26 | + | ||
| 27 | + th, err := theme.Load(theme.DefaultName, "") | ||
| 28 | + if err != nil { | ||
| 29 | + t.Fatalf("loading the theme: %v", err) | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + view.SetFocused(true) | ||
| 33 | + view.Draw(ui.NewPainter(screen), th) | ||
| 34 | + screen.Show() | ||
| 35 | + | ||
| 36 | + cells, w, h := screen.GetContents() | ||
| 37 | + rows = make([]string, h) | ||
| 38 | + for row := range h { | ||
| 39 | + var text strings.Builder | ||
| 40 | + for col := range w { | ||
| 41 | + text.WriteString(string(cells[row*w+col].Runes)) | ||
| 42 | + } | ||
| 43 | + rows[row] = strings.TrimRight(text.String(), " ") | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + x, y, visible := screen.GetCursor() | ||
| 47 | + if !visible { | ||
| 48 | + return rows, -1, -1 | ||
| 49 | + } | ||
| 50 | + return rows, x, y | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +// inputRows returns the rows of the box you type in: everything below the | ||
| 54 | +// rule. | ||
| 55 | +func inputRows(rows []string) []string { | ||
| 56 | + for i, row := range rows { | ||
| 57 | + if strings.Contains(row, "─") { | ||
| 58 | + return rows[i+1:] | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | + return nil | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +func typeInto(view *acp.View, text string) { | ||
| 65 | + for _, r := range text { | ||
| 66 | + view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +func TestALongPromptWrapsAtSpacesInsideTheBox(t *testing.T) { | ||
| 71 | + // This is the feature: before, the line was cut off with an ellipsis at | ||
| 72 | + // the edge and the rest of what you typed was invisible. | ||
| 73 | + view, _ := newView(t, 30, 12) | ||
| 74 | + typeInto(view, "please explain how the wrapping of a long prompt works") | ||
| 75 | + | ||
| 76 | + rows, _, _ := drawWithCursor(t, view, 30, 12) | ||
| 77 | + box := inputRows(rows) | ||
| 78 | + | ||
| 79 | + if !strings.HasPrefix(box[0], "> please explain how the") || box[1] != " wrapping of a long prompt" || box[2] != " works" { | ||
| 80 | + t.Errorf("the box is not wrapped at spaces:\n%s", strings.Join(box, "\n")) | ||
| 81 | + } | ||
| 82 | + if strings.Contains(strings.Join(box, "\n"), "…") { | ||
| 83 | + t.Errorf("the box still cuts the prompt off:\n%s", strings.Join(box, "\n")) | ||
| 84 | + } | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +func TestTheCursorFollowsTheTextOntoTheWrappedRow(t *testing.T) { | ||
| 88 | + view, _ := newView(t, 30, 12) | ||
| 89 | + typeInto(view, "please explain how the wrapping") | ||
| 90 | + | ||
| 91 | + _, x, y := drawWithCursor(t, view, 30, 12) | ||
| 92 | + | ||
| 93 | + // Row 0 of the box is screen row 9 (12 rows: 8 of conversation, the rule, | ||
| 94 | + // then 3 of box); "wrapping" is 8 runes on the second row, after "> " and | ||
| 95 | + // its own indent. | ||
| 96 | + if x != 2+len("wrapping") || y != 10 { | ||
| 97 | + t.Errorf("cursor at (%d, %d), want it after %q on the second row (%d, %d)", x, y, "wrapping", 2+len("wrapping"), 10) | ||
| 98 | + } | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +func TestTheCursorAtTheEndOfAFullRowStaysInsideThePane(t *testing.T) { | ||
| 102 | + // A row holds width-3 runes: prompt, space, and a column for exactly | ||
| 103 | + // this cursor. Without it the cursor sat on the frame and tcell hid it. | ||
| 104 | + view, _ := newView(t, 30, 12) | ||
| 105 | + typeInto(view, strings.Repeat("x", 27)) | ||
| 106 | + | ||
| 107 | + _, x, y := drawWithCursor(t, view, 30, 12) | ||
| 108 | + | ||
| 109 | + if x != 29 || y != 9 { | ||
| 110 | + t.Errorf("cursor at (%d, %d), want (29, 9): the last column of the pane, first row of the box", x, y) | ||
| 111 | + } | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +func TestAWordLongerThanTheBoxBreaksAtTheEdgeRatherThanVanishing(t *testing.T) { | ||
| 115 | + view, _ := newView(t, 30, 12) | ||
| 116 | + typeInto(view, strings.Repeat("a", 40)) | ||
| 117 | + | ||
| 118 | + rows, _, _ := drawWithCursor(t, view, 30, 12) | ||
| 119 | + box := inputRows(rows) | ||
| 120 | + | ||
| 121 | + if box[0] != "> "+strings.Repeat("a", 27) || box[1] != " "+strings.Repeat("a", 13) { | ||
| 122 | + t.Errorf("a long word is not broken at the edge:\n%s", strings.Join(box, "\n")) | ||
| 123 | + } | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +func TestUpAndDownMoveByRowInsideAWrappedPrompt(t *testing.T) { | ||
| 127 | + // Three rows to the eye are three rows to the arrow keys. Up from the last | ||
| 128 | + // row lands on the middle one, not on the conversation. | ||
| 129 | + view, _ := newView(t, 30, 12) | ||
| 130 | + typeInto(view, "please explain how the wrapping of a long prompt works") | ||
| 131 | + | ||
| 132 | + if !view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) { | ||
| 133 | + t.Fatal("Up was not taken by the box, though there is a row above the cursor") | ||
| 134 | + } | ||
| 135 | + _, x, y := drawWithCursor(t, view, 30, 12) | ||
| 136 | + if y != 10 || x != 2+len("works") { | ||
| 137 | + t.Errorf("after Up the cursor is at (%d, %d), want the middle row at the same column (%d, 10)", x, y, 2+len("works")) | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) | ||
| 141 | + view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) | ||
| 142 | + _, x, y = drawWithCursor(t, view, 30, 12) | ||
| 143 | + if y != 10 || x != 2+len("works") { | ||
| 144 | + t.Errorf("Up then Down did not come back: cursor at (%d, %d)", x, y) | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + // The column is kept, not the word: five in on the middle row is between | ||
| 148 | + // "wrapp" and "ing", which is where a key pressed now goes. | ||
| 149 | + typeInto(view, "!") | ||
| 150 | + if got := view.Input(); got != "please explain how the wrapp!ing of a long prompt works" { | ||
| 151 | + t.Errorf("typing after the moves went to the wrong place: %q", got) | ||
| 152 | + } | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +func TestUpFromTheFirstRowOfTheBoxIsLeftForTheConversation(t *testing.T) { | ||
| 156 | + view, _ := newView(t, 30, 12) | ||
| 157 | + typeInto(view, "short") | ||
| 158 | + | ||
| 159 | + if view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) && view.Input() != "short" { | ||
| 160 | + t.Error("Up on a one-row prompt changed the input") | ||
| 161 | + } | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +func TestAPromptTallerThanTheBoxScrollsToKeepTheCursorInSight(t *testing.T) { | ||
| 165 | + // Three rows of box, five rows of prompt: the first two go up under the | ||
| 166 | + // rule, and the prompt marker goes with them. | ||
| 167 | + view, _ := newView(t, 30, 12) | ||
| 168 | + typeInto(view, "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty") | ||
| 169 | + | ||
| 170 | + rows, _, y := drawWithCursor(t, view, 30, 12) | ||
| 171 | + box := inputRows(rows) | ||
| 172 | + | ||
| 173 | + if y != 11 { | ||
| 174 | + t.Errorf("the cursor is on screen row %d, want the last row of the box, 11", y) | ||
| 175 | + } | ||
| 176 | + if strings.HasPrefix(box[0], ">") { | ||
| 177 | + t.Errorf("the prompt marker is beside a row that is not the first:\n%s", strings.Join(box, "\n")) | ||
| 178 | + } | ||
| 179 | + if !strings.HasSuffix(box[2], "twenty") { | ||
| 180 | + t.Errorf("the last row does not end with what was typed last:\n%s", strings.Join(box, "\n")) | ||
| 181 | + } | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +func TestATypedTrailingSpaceAtTheEdgePutsTheCursorOnTheNextRow(t *testing.T) { | ||
| 185 | + // 27 runes fill a row; the space after them is the break, not drawn, and | ||
| 186 | + // the cursor is at the start of an empty row below rather than on the frame. | ||
| 187 | + view, _ := newView(t, 30, 12) | ||
| 188 | + typeInto(view, strings.Repeat("x", 27)+" ") | ||
| 189 | + | ||
| 190 | + _, x, y := drawWithCursor(t, view, 30, 12) | ||
| 191 | + if x != 2 || y != 10 { | ||
| 192 | + t.Errorf("cursor at (%d, %d), want (2, 10)", x, y) | ||
| 193 | + } | ||
| 194 | +} | ||
added
acp/paste.go +214 -0 | new file mode 100644 | ||
| @@ -0,0 +1,214 @@ | ||
| 1 | +// Pasting into the box: a long paste becomes a token, and the agent gets the | |
| 2 | +// text the token stands for. | |
| 3 | + | |
| 4 | +package acp | |
| 5 | + | |
| 6 | +import ( | |
| 7 | + "fmt" | |
| 8 | + "strings" | |
| 9 | + "unicode/utf8" | |
| 10 | + | |
| 11 | + "github.com/gdamore/tcell/v2" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// Paste is text that was pasted into the box and kept aside: the box shows | |
| 15 | +// Token where it went, and the agent receives Text in its place. | |
| 16 | +// | |
| 17 | +// acp.Paste{Token: "[Pasted #1 · 6 lines · 700 chars]", Text: trace} | |
| 18 | +type Paste struct { | |
| 19 | + Token string | |
| 20 | + Text string | |
| 21 | +} | |
| 22 | + | |
| 23 | +// pasteInlineLimit is how long a single-line paste may be and still go into | |
| 24 | +// the box as if typed. Longer, and it is kept aside like a multi-line one: a | |
| 25 | +// box three rows tall filled by one paste is as unreadable as forty lines. | |
| 26 | +const pasteInlineLimit = 200 | |
| 27 | + | |
| 28 | +// Paste puts pasted text into the box at the cursor, whichever pane had the | |
| 29 | +// focus — a paste is something to send, and the box is where that happens. | |
| 30 | +// | |
| 31 | +// A short single line goes in as if typed: a path, a command, an identifier | |
| 32 | +// is what you want to edit around. Anything with a line break in it, or | |
| 33 | +// longer than pasteInlineLimit, is **kept aside and stands in the box as a | |
| 34 | +// token** — `[Pasted #1 · 6 lines · 700 chars]` — because a box a few rows | |
| 35 | +// tall cannot show forty lines of log, and what was typed around them would be | |
| 36 | +// pushed off the screen. Send gives the agent the text, byte for byte, where | |
| 37 | +// the token was; the conversation keeps the token, so the exchange stays | |
| 38 | +// readable afterwards too. Deleting the paste instead is no answer: the text | |
| 39 | +// is what the model needs. | |
| 40 | +// | |
| 41 | +// A trailing newline is dropped: copying a line usually brings one along, and | |
| 42 | +// it would otherwise make a one-line paste a two-line token. | |
| 43 | +func (v *View) Paste(text string) { | |
| 44 | + text = strings.ReplaceAll(text, "\r\n", "\n") | |
| 45 | + text = strings.ReplaceAll(text, "\r", "\n") | |
| 46 | + text = strings.TrimSuffix(text, "\n") | |
| 47 | + if text == "" { | |
| 48 | + return | |
| 49 | + } | |
| 50 | + v.onInput = true | |
| 51 | + | |
| 52 | + if !strings.Contains(text, "\n") && utf8.RuneCountInString(text) <= pasteInlineLimit { | |
| 53 | + v.insertText(text) | |
| 54 | + return | |
| 55 | + } | |
| 56 | + | |
| 57 | + v.pasteCount++ | |
| 58 | + paste := Paste{Token: pasteToken(v.pasteCount, text), Text: text} | |
| 59 | + v.pastes = append(v.pastes, paste) | |
| 60 | + v.insertText(paste.Token) | |
| 61 | +} | |
| 62 | + | |
| 63 | +// pasteToken is what stands in the box for a paste: its number, so two are | |
| 64 | +// told apart, and its size, so you know what you are sending. | |
| 65 | +func pasteToken(number int, text string) string { | |
| 66 | + lines := strings.Count(text, "\n") + 1 | |
| 67 | + return fmt.Sprintf("[Pasted #%d · %s · %s]", number, counted(lines, "line"), counted(utf8.RuneCountInString(text), "char")) | |
| 68 | +} | |
| 69 | + | |
| 70 | +// counted renders a count with its noun. | |
| 71 | +func counted(count int, noun string) string { | |
| 72 | + if count == 1 { | |
| 73 | + return "1 " + noun | |
| 74 | + } | |
| 75 | + return fmt.Sprintf("%d %ss", count, noun) | |
| 76 | +} | |
| 77 | + | |
| 78 | +// insertText types a run of characters — no line breaks — at the cursor. | |
| 79 | +func (v *View) insertText(text string) { | |
| 80 | + for _, r := range text { | |
| 81 | + v.insert(r) | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +// pasteClipboard pastes what the editor's clipboard holds, when the editor | |
| 86 | +// has said how to read it. | |
| 87 | +func (v *View) pasteClipboard() bool { | |
| 88 | + if v.OnPaste == nil { | |
| 89 | + return false | |
| 90 | + } | |
| 91 | + v.Paste(v.OnPaste()) | |
| 92 | + return true | |
| 93 | +} | |
| 94 | + | |
| 95 | +// isPasteKey reports whether a key is the editor's own Paste — Shift-Ins, as | |
| 96 | +// the Edit menu says. Ctrl-V is accepted beside it in handleWindowKey, as | |
| 97 | +// Ctrl-C is for Copy. | |
| 98 | +func isPasteKey(ev *tcell.EventKey) bool { | |
| 99 | + return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0 | |
| 100 | +} | |
| 101 | + | |
| 102 | +// pastesIn returns the pastes whose token is still in a text — the ones Send | |
| 103 | +// has to expand. A token that was deleted takes its text with it. | |
| 104 | +func (v *View) pastesIn(text string) []Paste { | |
| 105 | + var out []Paste | |
| 106 | + for _, paste := range v.pastes { | |
| 107 | + if strings.Contains(text, paste.Token) { | |
| 108 | + out = append(out, paste) | |
| 109 | + } | |
| 110 | + } | |
| 111 | + return out | |
| 112 | +} | |
| 113 | + | |
| 114 | +// tokenSpan is where a paste token sits in a line, in runes. | |
| 115 | +type tokenSpan struct{ start, end int } | |
| 116 | + | |
| 117 | +// tokensIn returns every paste token in a line, in order. | |
| 118 | +// | |
| 119 | +// Tokens are found by their text, not remembered by position: the line is | |
| 120 | +// edited by a dozen functions, and a position kept alongside would be wrong | |
| 121 | +// the first time one of them forgot to move it. | |
| 122 | +func (v *View) tokensIn(line string) []tokenSpan { | |
| 123 | + var spans []tokenSpan | |
| 124 | + for _, paste := range v.pastes { | |
| 125 | + rest, offset := line, 0 | |
| 126 | + for { | |
| 127 | + at := strings.Index(rest, paste.Token) | |
| 128 | + if at < 0 { | |
| 129 | + break | |
| 130 | + } | |
| 131 | + start := offset + utf8.RuneCountInString(rest[:at]) | |
| 132 | + spans = append(spans, tokenSpan{start: start, end: start + utf8.RuneCountInString(paste.Token)}) | |
| 133 | + skip := at + len(paste.Token) | |
| 134 | + rest, offset = rest[skip:], start+utf8.RuneCountInString(paste.Token) | |
| 135 | + } | |
| 136 | + } | |
| 137 | + return spans | |
| 138 | +} | |
| 139 | + | |
| 140 | +// tokenEndingAt returns the token whose last rune is just before a column. | |
| 141 | +func (v *View) tokenEndingAt(column int) (tokenSpan, bool) { | |
| 142 | + for _, span := range v.tokensIn(v.input[v.cursor]) { | |
| 143 | + if span.end == column { | |
| 144 | + return span, true | |
| 145 | + } | |
| 146 | + } | |
| 147 | + return tokenSpan{}, false | |
| 148 | +} | |
| 149 | + | |
| 150 | +// tokenStartingAt returns the token whose first rune is at a column. | |
| 151 | +func (v *View) tokenStartingAt(column int) (tokenSpan, bool) { | |
| 152 | + for _, span := range v.tokensIn(v.input[v.cursor]) { | |
| 153 | + if span.start == column { | |
| 154 | + return span, true | |
| 155 | + } | |
| 156 | + } | |
| 157 | + return tokenSpan{}, false | |
| 158 | +} | |
| 159 | + | |
| 160 | +// leaveToken moves a cursor that has landed inside a token to its nearer | |
| 161 | +// edge. Arrow keys step over tokens, so only a move by row can land inside | |
| 162 | +// one; a token is one thing, and typing into the middle of it would break it | |
| 163 | +// into text that stands for nothing. | |
| 164 | +func (v *View) leaveToken() { | |
| 165 | + for _, span := range v.tokensIn(v.input[v.cursor]) { | |
| 166 | + if v.column > span.start && v.column < span.end { | |
| 167 | + if v.column-span.start < span.end-v.column { | |
| 168 | + v.column = span.start | |
| 169 | + } else { | |
| 170 | + v.column = span.end | |
| 171 | + } | |
| 172 | + return | |
| 173 | + } | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +// removeSpan takes a run of runes out of the cursor's line and leaves the | |
| 178 | +// cursor where the run began. | |
| 179 | +func (v *View) removeSpan(span tokenSpan) { | |
| 180 | + runes := v.runes() | |
| 181 | + v.input[v.cursor] = string(append(append([]rune{}, runes[:span.start]...), runes[span.end:]...)) | |
| 182 | + v.column = span.start | |
| 183 | +} | |
| 184 | + | |
| 185 | +// inToken reports whether a column of a line is inside one of its tokens. | |
| 186 | +func inToken(spans []tokenSpan, at int) bool { | |
| 187 | + for _, span := range spans { | |
| 188 | + if at >= span.start && at < span.end { | |
| 189 | + return true | |
| 190 | + } | |
| 191 | + } | |
| 192 | + return false | |
| 193 | +} | |
| 194 | + | |
| 195 | +// expandPastes puts each paste's text where its token stands, in the text | |
| 196 | +// blocks of a prompt and nowhere else. | |
| 197 | +// | |
| 198 | +// After the mentions have been cut out, not before: a paste is sent byte for | |
| 199 | +// byte, so an "@name" inside a pasted log must stay the characters it is and | |
| 200 | +// not become a file. | |
| 201 | +func expandPastes(blocks []ContentBlock, pastes []Paste) []ContentBlock { | |
| 202 | + if len(pastes) == 0 { | |
| 203 | + return blocks | |
| 204 | + } | |
| 205 | + for i := range blocks { | |
| 206 | + if blocks[i].Type != ContentText { | |
| 207 | + continue | |
| 208 | + } | |
| 209 | + for _, paste := range pastes { | |
| 210 | + blocks[i].Text = strings.ReplaceAll(blocks[i].Text, paste.Token, paste.Text) | |
| 211 | + } | |
| 212 | + } | |
| 213 | + return blocks | |
| 214 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,214 @@ | |||
| 1 | +// Pasting into the box: a long paste becomes a token, and the agent gets the | ||
| 2 | +// text the token stands for. | ||
| 3 | + | ||
| 4 | +package acp | ||
| 5 | + | ||
| 6 | +import ( | ||
| 7 | + "fmt" | ||
| 8 | + "strings" | ||
| 9 | + "unicode/utf8" | ||
| 10 | + | ||
| 11 | + "github.com/gdamore/tcell/v2" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// Paste is text that was pasted into the box and kept aside: the box shows | ||
| 15 | +// Token where it went, and the agent receives Text in its place. | ||
| 16 | +// | ||
| 17 | +// acp.Paste{Token: "[Pasted #1 · 6 lines · 700 chars]", Text: trace} | ||
| 18 | +type Paste struct { | ||
| 19 | + Token string | ||
| 20 | + Text string | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +// pasteInlineLimit is how long a single-line paste may be and still go into | ||
| 24 | +// the box as if typed. Longer, and it is kept aside like a multi-line one: a | ||
| 25 | +// box three rows tall filled by one paste is as unreadable as forty lines. | ||
| 26 | +const pasteInlineLimit = 200 | ||
| 27 | + | ||
| 28 | +// Paste puts pasted text into the box at the cursor, whichever pane had the | ||
| 29 | +// focus — a paste is something to send, and the box is where that happens. | ||
| 30 | +// | ||
| 31 | +// A short single line goes in as if typed: a path, a command, an identifier | ||
| 32 | +// is what you want to edit around. Anything with a line break in it, or | ||
| 33 | +// longer than pasteInlineLimit, is **kept aside and stands in the box as a | ||
| 34 | +// token** — `[Pasted #1 · 6 lines · 700 chars]` — because a box a few rows | ||
| 35 | +// tall cannot show forty lines of log, and what was typed around them would be | ||
| 36 | +// pushed off the screen. Send gives the agent the text, byte for byte, where | ||
| 37 | +// the token was; the conversation keeps the token, so the exchange stays | ||
| 38 | +// readable afterwards too. Deleting the paste instead is no answer: the text | ||
| 39 | +// is what the model needs. | ||
| 40 | +// | ||
| 41 | +// A trailing newline is dropped: copying a line usually brings one along, and | ||
| 42 | +// it would otherwise make a one-line paste a two-line token. | ||
| 43 | +func (v *View) Paste(text string) { | ||
| 44 | + text = strings.ReplaceAll(text, "\r\n", "\n") | ||
| 45 | + text = strings.ReplaceAll(text, "\r", "\n") | ||
| 46 | + text = strings.TrimSuffix(text, "\n") | ||
| 47 | + if text == "" { | ||
| 48 | + return | ||
| 49 | + } | ||
| 50 | + v.onInput = true | ||
| 51 | + | ||
| 52 | + if !strings.Contains(text, "\n") && utf8.RuneCountInString(text) <= pasteInlineLimit { | ||
| 53 | + v.insertText(text) | ||
| 54 | + return | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + v.pasteCount++ | ||
| 58 | + paste := Paste{Token: pasteToken(v.pasteCount, text), Text: text} | ||
| 59 | + v.pastes = append(v.pastes, paste) | ||
| 60 | + v.insertText(paste.Token) | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// pasteToken is what stands in the box for a paste: its number, so two are | ||
| 64 | +// told apart, and its size, so you know what you are sending. | ||
| 65 | +func pasteToken(number int, text string) string { | ||
| 66 | + lines := strings.Count(text, "\n") + 1 | ||
| 67 | + return fmt.Sprintf("[Pasted #%d · %s · %s]", number, counted(lines, "line"), counted(utf8.RuneCountInString(text), "char")) | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// counted renders a count with its noun. | ||
| 71 | +func counted(count int, noun string) string { | ||
| 72 | + if count == 1 { | ||
| 73 | + return "1 " + noun | ||
| 74 | + } | ||
| 75 | + return fmt.Sprintf("%d %ss", count, noun) | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +// insertText types a run of characters — no line breaks — at the cursor. | ||
| 79 | +func (v *View) insertText(text string) { | ||
| 80 | + for _, r := range text { | ||
| 81 | + v.insert(r) | ||
| 82 | + } | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +// pasteClipboard pastes what the editor's clipboard holds, when the editor | ||
| 86 | +// has said how to read it. | ||
| 87 | +func (v *View) pasteClipboard() bool { | ||
| 88 | + if v.OnPaste == nil { | ||
| 89 | + return false | ||
| 90 | + } | ||
| 91 | + v.Paste(v.OnPaste()) | ||
| 92 | + return true | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +// isPasteKey reports whether a key is the editor's own Paste — Shift-Ins, as | ||
| 96 | +// the Edit menu says. Ctrl-V is accepted beside it in handleWindowKey, as | ||
| 97 | +// Ctrl-C is for Copy. | ||
| 98 | +func isPasteKey(ev *tcell.EventKey) bool { | ||
| 99 | + return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0 | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +// pastesIn returns the pastes whose token is still in a text — the ones Send | ||
| 103 | +// has to expand. A token that was deleted takes its text with it. | ||
| 104 | +func (v *View) pastesIn(text string) []Paste { | ||
| 105 | + var out []Paste | ||
| 106 | + for _, paste := range v.pastes { | ||
| 107 | + if strings.Contains(text, paste.Token) { | ||
| 108 | + out = append(out, paste) | ||
| 109 | + } | ||
| 110 | + } | ||
| 111 | + return out | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +// tokenSpan is where a paste token sits in a line, in runes. | ||
| 115 | +type tokenSpan struct{ start, end int } | ||
| 116 | + | ||
| 117 | +// tokensIn returns every paste token in a line, in order. | ||
| 118 | +// | ||
| 119 | +// Tokens are found by their text, not remembered by position: the line is | ||
| 120 | +// edited by a dozen functions, and a position kept alongside would be wrong | ||
| 121 | +// the first time one of them forgot to move it. | ||
| 122 | +func (v *View) tokensIn(line string) []tokenSpan { | ||
| 123 | + var spans []tokenSpan | ||
| 124 | + for _, paste := range v.pastes { | ||
| 125 | + rest, offset := line, 0 | ||
| 126 | + for { | ||
| 127 | + at := strings.Index(rest, paste.Token) | ||
| 128 | + if at < 0 { | ||
| 129 | + break | ||
| 130 | + } | ||
| 131 | + start := offset + utf8.RuneCountInString(rest[:at]) | ||
| 132 | + spans = append(spans, tokenSpan{start: start, end: start + utf8.RuneCountInString(paste.Token)}) | ||
| 133 | + skip := at + len(paste.Token) | ||
| 134 | + rest, offset = rest[skip:], start+utf8.RuneCountInString(paste.Token) | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + return spans | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// tokenEndingAt returns the token whose last rune is just before a column. | ||
| 141 | +func (v *View) tokenEndingAt(column int) (tokenSpan, bool) { | ||
| 142 | + for _, span := range v.tokensIn(v.input[v.cursor]) { | ||
| 143 | + if span.end == column { | ||
| 144 | + return span, true | ||
| 145 | + } | ||
| 146 | + } | ||
| 147 | + return tokenSpan{}, false | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +// tokenStartingAt returns the token whose first rune is at a column. | ||
| 151 | +func (v *View) tokenStartingAt(column int) (tokenSpan, bool) { | ||
| 152 | + for _, span := range v.tokensIn(v.input[v.cursor]) { | ||
| 153 | + if span.start == column { | ||
| 154 | + return span, true | ||
| 155 | + } | ||
| 156 | + } | ||
| 157 | + return tokenSpan{}, false | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +// leaveToken moves a cursor that has landed inside a token to its nearer | ||
| 161 | +// edge. Arrow keys step over tokens, so only a move by row can land inside | ||
| 162 | +// one; a token is one thing, and typing into the middle of it would break it | ||
| 163 | +// into text that stands for nothing. | ||
| 164 | +func (v *View) leaveToken() { | ||
| 165 | + for _, span := range v.tokensIn(v.input[v.cursor]) { | ||
| 166 | + if v.column > span.start && v.column < span.end { | ||
| 167 | + if v.column-span.start < span.end-v.column { | ||
| 168 | + v.column = span.start | ||
| 169 | + } else { | ||
| 170 | + v.column = span.end | ||
| 171 | + } | ||
| 172 | + return | ||
| 173 | + } | ||
| 174 | + } | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +// removeSpan takes a run of runes out of the cursor's line and leaves the | ||
| 178 | +// cursor where the run began. | ||
| 179 | +func (v *View) removeSpan(span tokenSpan) { | ||
| 180 | + runes := v.runes() | ||
| 181 | + v.input[v.cursor] = string(append(append([]rune{}, runes[:span.start]...), runes[span.end:]...)) | ||
| 182 | + v.column = span.start | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +// inToken reports whether a column of a line is inside one of its tokens. | ||
| 186 | +func inToken(spans []tokenSpan, at int) bool { | ||
| 187 | + for _, span := range spans { | ||
| 188 | + if at >= span.start && at < span.end { | ||
| 189 | + return true | ||
| 190 | + } | ||
| 191 | + } | ||
| 192 | + return false | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +// expandPastes puts each paste's text where its token stands, in the text | ||
| 196 | +// blocks of a prompt and nowhere else. | ||
| 197 | +// | ||
| 198 | +// After the mentions have been cut out, not before: a paste is sent byte for | ||
| 199 | +// byte, so an "@name" inside a pasted log must stay the characters it is and | ||
| 200 | +// not become a file. | ||
| 201 | +func expandPastes(blocks []ContentBlock, pastes []Paste) []ContentBlock { | ||
| 202 | + if len(pastes) == 0 { | ||
| 203 | + return blocks | ||
| 204 | + } | ||
| 205 | + for i := range blocks { | ||
| 206 | + if blocks[i].Type != ContentText { | ||
| 207 | + continue | ||
| 208 | + } | ||
| 209 | + for _, paste := range pastes { | ||
| 210 | + blocks[i].Text = strings.ReplaceAll(blocks[i].Text, paste.Token, paste.Text) | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | + return blocks | ||
| 214 | +} | ||
added
acp/paste_test.go +273 -0 | new file mode 100644 | ||
| @@ -0,0 +1,273 @@ | ||
| 1 | +package acp_test | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "github.com/gdamore/tcell/v2" | |
| 8 | + | |
| 9 | + "rickub.com/turbo-editors/turbo-core/acp" | |
| 10 | + "rickub.com/turbo-editors/turbo-core/theme" | |
| 11 | + "rickub.com/turbo-editors/turbo-core/ui" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// trace is six lines of the kind of thing that gets pasted into an agent's | |
| 15 | +// box: a log, longer than the box is tall. | |
| 16 | +const trace = "panic: runtime error: index out of range [3] with length 3\n" + | |
| 17 | + "goroutine 1 [running]:\n" + | |
| 18 | + "main.pick(...)\n" + | |
| 19 | + "\t/src/p/main.go:12\n" + | |
| 20 | + "main.main()\n" + | |
| 21 | + "\t/src/p/main.go:7 +0x1d" | |
| 22 | + | |
| 23 | +func TestAMultiLinePasteStandsInTheBoxAsAToken(t *testing.T) { | |
| 24 | + // The ticket's own example: a paste a few rows tall would make the box | |
| 25 | + // unreadable and push what was typed around it off the screen. | |
| 26 | + view, _ := newView(t, 60, 12) | |
| 27 | + typeInto(view, "here is the trace ") | |
| 28 | + view.Paste(trace) | |
| 29 | + typeInto(view, " what is failing?") | |
| 30 | + | |
| 31 | + want := "here is the trace [Pasted #1 · 6 lines · 151 chars] what is failing?" | |
| 32 | + if got := view.Input(); got != want { | |
| 33 | + t.Errorf("Input() = %q\nwant %q", got, want) | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +func TestAShortSingleLinePasteGoesInAsIfTyped(t *testing.T) { | |
| 38 | + // A path, a command, an identifier: what you paste to edit around. The | |
| 39 | + // newline copying a line brings along is dropped, not made a second line. | |
| 40 | + view, _ := newView(t, 60, 12) | |
| 41 | + view.Paste("go build ./...\n") | |
| 42 | + | |
| 43 | + if got := view.Input(); got != "go build ./..." { | |
| 44 | + t.Errorf("Input() = %q, want the line itself", got) | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +func TestALongSingleLinePasteIsKeptAsideToo(t *testing.T) { | |
| 49 | + view, _ := newView(t, 60, 12) | |
| 50 | + view.Paste(strings.Repeat("x", 300)) | |
| 51 | + | |
| 52 | + if got := view.Input(); got != "[Pasted #1 · 1 line · 300 chars]" { | |
| 53 | + t.Errorf("Input() = %q, want a token for one long line", got) | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +func TestWindowsLineEndingsCountAsLines(t *testing.T) { | |
| 58 | + view, _ := newView(t, 60, 12) | |
| 59 | + view.Paste("one\r\ntwo\r\nthree\r\n") | |
| 60 | + | |
| 61 | + if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { | |
| 62 | + t.Errorf("Input() = %q", got) | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +func TestTheAgentReceivesThePastedTextWhereTheTokenStood(t *testing.T) { | |
| 67 | + // This is the point: the token is for the box; the model gets the bytes. | |
| 68 | + view, agent := mentioningView(t, nil) | |
| 69 | + agent.handshake() | |
| 70 | + waitFor(t, "ready", view.Session().Ready) | |
| 71 | + | |
| 72 | + typeInto(view, "here is the trace ") | |
| 73 | + view.Paste(trace) | |
| 74 | + typeInto(view, " what is failing?") | |
| 75 | + view.Send() | |
| 76 | + | |
| 77 | + blocks := promptBlocks(t, agent.read()) | |
| 78 | + if len(blocks) != 1 || blocks[0]["type"] != "text" { | |
| 79 | + t.Fatalf("the prompt is %v, want one text block", blocks) | |
| 80 | + } | |
| 81 | + if got := blocks[0]["text"]; got != "here is the trace "+trace+" what is failing?" { | |
| 82 | + t.Errorf("the agent received %q", got) | |
| 83 | + } | |
| 84 | + | |
| 85 | + // The conversation keeps the token, so the exchange stays readable. | |
| 86 | + if got := textOf(view.Session().Entries(), acp.EntryUser); !strings.Contains(got, "[Pasted #1 · 6 lines") || strings.Contains(got, "goroutine") { | |
| 87 | + t.Errorf("the conversation says %q, want the token and not the trace", got) | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +func TestAFileNamedInsideAPasteIsNotTakenForAMention(t *testing.T) { | |
| 92 | + // A paste is sent byte for byte. An "@name" in a pasted log is characters, | |
| 93 | + // not a request to attach the file. | |
| 94 | + view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil }) | |
| 95 | + agent.handshake() | |
| 96 | + waitFor(t, "ready", view.Session().Ready) | |
| 97 | + | |
| 98 | + pasted := "see @internal/scanner.go for details\nand the next line" | |
| 99 | + view.Paste(pasted) | |
| 100 | + view.Send() | |
| 101 | + | |
| 102 | + blocks := promptBlocks(t, agent.read()) | |
| 103 | + if len(blocks) != 1 || blocks[0]["type"] != "text" || blocks[0]["text"] != pasted { | |
| 104 | + t.Errorf("the prompt is %v, want the pasted text alone, verbatim", blocks) | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +func TestAMentionTypedBesideATokenStillWorks(t *testing.T) { | |
| 109 | + view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil }) | |
| 110 | + agent.handshake() | |
| 111 | + waitFor(t, "ready", view.Session().Ready) | |
| 112 | + | |
| 113 | + typeInto(view, "compare @internal/scanner.go with ") | |
| 114 | + view.Paste(trace) | |
| 115 | + view.Send() | |
| 116 | + | |
| 117 | + blocks := promptBlocks(t, agent.read()) | |
| 118 | + if len(blocks) != 3 || blocks[1]["type"] != "resource" { | |
| 119 | + t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks) | |
| 120 | + } | |
| 121 | + if blocks[2]["text"] != " with "+trace { | |
| 122 | + t.Errorf("block 2 = %q, want the words and the trace", blocks[2]["text"]) | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +func TestBackspaceRemovesAWholeToken(t *testing.T) { | |
| 127 | + // A token missing its last bracket stands for nothing, so the token is | |
| 128 | + // one thing to the editing keys. | |
| 129 | + view, agent := mentioningView(t, nil) | |
| 130 | + agent.handshake() | |
| 131 | + waitFor(t, "ready", view.Session().Ready) | |
| 132 | + | |
| 133 | + typeInto(view, "a ") | |
| 134 | + view.Paste(trace) | |
| 135 | + view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone)) | |
| 136 | + | |
| 137 | + if got := view.Input(); got != "a " { | |
| 138 | + t.Errorf("Input() = %q after Backspace, want the token gone whole", got) | |
| 139 | + } | |
| 140 | + | |
| 141 | + // And its text goes with it: nothing of the trace reaches the agent. | |
| 142 | + typeInto(view, "b") | |
| 143 | + view.Send() | |
| 144 | + blocks := promptBlocks(t, agent.read()) | |
| 145 | + if len(blocks) != 1 || blocks[0]["text"] != "a b" { | |
| 146 | + t.Errorf("the agent received %v, want just %q", blocks, "a b") | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +func TestDeleteAtTheStartOfATokenRemovesItWhole(t *testing.T) { | |
| 151 | + view, _ := newView(t, 60, 12) | |
| 152 | + view.Paste(trace) | |
| 153 | + typeInto(view, "!") | |
| 154 | + view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) | |
| 155 | + view.HandleKey(tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone)) | |
| 156 | + | |
| 157 | + if got := view.Input(); got != "!" { | |
| 158 | + t.Errorf("Input() = %q after Delete at the token's start", got) | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +func TestTheArrowKeysStepOverATokenRatherThanInto(t *testing.T) { | |
| 163 | + view, _ := newView(t, 60, 12) | |
| 164 | + view.Paste(trace) | |
| 165 | + typeInto(view, "x") | |
| 166 | + | |
| 167 | + view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // before x | |
| 168 | + view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // over the token | |
| 169 | + typeInto(view, "y") | |
| 170 | + view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) // over it again | |
| 171 | + typeInto(view, "z") | |
| 172 | + | |
| 173 | + if got := view.Input(); got != "y[Pasted #1 · 6 lines · 151 chars]zx" { | |
| 174 | + t.Errorf("Input() = %q", got) | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +func TestAMoveByRowThatLandsInsideATokenIsPushedToItsEdge(t *testing.T) { | |
| 179 | + // Only ↑/↓ can land inside one; typing into the middle of a token would | |
| 180 | + // break it into text that stands for nothing. | |
| 181 | + view, _ := newView(t, 40, 12) // rows of 37 runes | |
| 182 | + typeInto(view, strings.Repeat("a", 30)+" ") | |
| 183 | + view.Paste(trace) // the 34-rune token wraps onto the second row | |
| 184 | + view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) | |
| 185 | + view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) | |
| 186 | + view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) | |
| 187 | + view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) // column 1 of the token's row | |
| 188 | + typeInto(view, "!") | |
| 189 | + | |
| 190 | + got := view.Input() | |
| 191 | + if !strings.Contains(got, "![Pasted") && !strings.Contains(got, "chars]!") { | |
| 192 | + t.Errorf("Input() = %q, want the ! at an edge of the token", got) | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +func TestATokenIsDrawnInItsOwnColour(t *testing.T) { | |
| 197 | + view, _ := newView(t, 60, 12) | |
| 198 | + typeInto(view, "see ") | |
| 199 | + view.Paste(trace) | |
| 200 | + | |
| 201 | + screen := tcell.NewSimulationScreen("UTF-8") | |
| 202 | + if err := screen.Init(); err != nil { | |
| 203 | + t.Fatal(err) | |
| 204 | + } | |
| 205 | + defer screen.Fini() | |
| 206 | + screen.SetSize(60, 12) | |
| 207 | + th, err := theme.Load(theme.DefaultName, "") | |
| 208 | + if err != nil { | |
| 209 | + t.Fatal(err) | |
| 210 | + } | |
| 211 | + view.Draw(ui.NewPainter(screen), th) | |
| 212 | + screen.Show() | |
| 213 | + | |
| 214 | + cells, w, _ := screen.GetContents() | |
| 215 | + row := 9 // the box's first row | |
| 216 | + word, token := cells[row*w+2].Style, cells[row*w+2+len("see ")].Style | |
| 217 | + if word == token { | |
| 218 | + t.Error("the token is drawn in the same colour as the words around it") | |
| 219 | + } | |
| 220 | + if got := string(cells[row*w+2+len("see ")].Runes); got != "[" { | |
| 221 | + t.Errorf("the token's first cell shows %q, want its bracket", got) | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +func TestCtrlVPastesWhatTheEditorsClipboardHolds(t *testing.T) { | |
| 226 | + view, _ := newView(t, 60, 12) | |
| 227 | + view.OnPaste = func() string { return "one\ntwo\nthree" } | |
| 228 | + | |
| 229 | + view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlV, 0, tcell.ModNone)) | |
| 230 | + | |
| 231 | + if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { | |
| 232 | + t.Errorf("Input() = %q after Ctrl-V", got) | |
| 233 | + } | |
| 234 | +} | |
| 235 | + | |
| 236 | +func TestShiftInsertPastesToo(t *testing.T) { | |
| 237 | + view, _ := newView(t, 60, 12) | |
| 238 | + view.OnPaste = func() string { return "short" } | |
| 239 | + | |
| 240 | + view.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) | |
| 241 | + | |
| 242 | + if got := view.Input(); got != "short" { | |
| 243 | + t.Errorf("Input() = %q after Shift-Ins", got) | |
| 244 | + } | |
| 245 | +} | |
| 246 | + | |
| 247 | +func TestPasteNumbersRunOnAcrossPrompts(t *testing.T) { | |
| 248 | + // "#1" in an earlier exchange is not the same thing as "#1" in this one. | |
| 249 | + view, agent := mentioningView(t, nil) | |
| 250 | + agent.handshake() | |
| 251 | + waitFor(t, "ready", view.Session().Ready) | |
| 252 | + | |
| 253 | + view.Paste(trace) | |
| 254 | + view.Send() | |
| 255 | + agent.read() | |
| 256 | + view.Paste(trace) | |
| 257 | + | |
| 258 | + if got := view.Input(); !strings.HasPrefix(got, "[Pasted #2 ") { | |
| 259 | + t.Errorf("the second paste is %q, want #2", got) | |
| 260 | + } | |
| 261 | +} | |
| 262 | + | |
| 263 | +func TestAPasteGoesIntoTheBoxWhicheverPaneHadTheFocus(t *testing.T) { | |
| 264 | + view, _ := newView(t, 60, 12) | |
| 265 | + view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // to the conversation | |
| 266 | + | |
| 267 | + view.Paste("hello") | |
| 268 | + typeInto(view, "!") | |
| 269 | + | |
| 270 | + if got := view.Input(); got != "hello!" { | |
| 271 | + t.Errorf("Input() = %q, want the paste and the keystroke after it in the box", got) | |
| 272 | + } | |
| 273 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,273 @@ | |||
| 1 | +package acp_test | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "github.com/gdamore/tcell/v2" | ||
| 8 | + | ||
| 9 | + "rickub.com/turbo-editors/turbo-core/acp" | ||
| 10 | + "rickub.com/turbo-editors/turbo-core/theme" | ||
| 11 | + "rickub.com/turbo-editors/turbo-core/ui" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// trace is six lines of the kind of thing that gets pasted into an agent's | ||
| 15 | +// box: a log, longer than the box is tall. | ||
| 16 | +const trace = "panic: runtime error: index out of range [3] with length 3\n" + | ||
| 17 | + "goroutine 1 [running]:\n" + | ||
| 18 | + "main.pick(...)\n" + | ||
| 19 | + "\t/src/p/main.go:12\n" + | ||
| 20 | + "main.main()\n" + | ||
| 21 | + "\t/src/p/main.go:7 +0x1d" | ||
| 22 | + | ||
| 23 | +func TestAMultiLinePasteStandsInTheBoxAsAToken(t *testing.T) { | ||
| 24 | + // The ticket's own example: a paste a few rows tall would make the box | ||
| 25 | + // unreadable and push what was typed around it off the screen. | ||
| 26 | + view, _ := newView(t, 60, 12) | ||
| 27 | + typeInto(view, "here is the trace ") | ||
| 28 | + view.Paste(trace) | ||
| 29 | + typeInto(view, " what is failing?") | ||
| 30 | + | ||
| 31 | + want := "here is the trace [Pasted #1 · 6 lines · 151 chars] what is failing?" | ||
| 32 | + if got := view.Input(); got != want { | ||
| 33 | + t.Errorf("Input() = %q\nwant %q", got, want) | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func TestAShortSingleLinePasteGoesInAsIfTyped(t *testing.T) { | ||
| 38 | + // A path, a command, an identifier: what you paste to edit around. The | ||
| 39 | + // newline copying a line brings along is dropped, not made a second line. | ||
| 40 | + view, _ := newView(t, 60, 12) | ||
| 41 | + view.Paste("go build ./...\n") | ||
| 42 | + | ||
| 43 | + if got := view.Input(); got != "go build ./..." { | ||
| 44 | + t.Errorf("Input() = %q, want the line itself", got) | ||
| 45 | + } | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +func TestALongSingleLinePasteIsKeptAsideToo(t *testing.T) { | ||
| 49 | + view, _ := newView(t, 60, 12) | ||
| 50 | + view.Paste(strings.Repeat("x", 300)) | ||
| 51 | + | ||
| 52 | + if got := view.Input(); got != "[Pasted #1 · 1 line · 300 chars]" { | ||
| 53 | + t.Errorf("Input() = %q, want a token for one long line", got) | ||
| 54 | + } | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +func TestWindowsLineEndingsCountAsLines(t *testing.T) { | ||
| 58 | + view, _ := newView(t, 60, 12) | ||
| 59 | + view.Paste("one\r\ntwo\r\nthree\r\n") | ||
| 60 | + | ||
| 61 | + if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { | ||
| 62 | + t.Errorf("Input() = %q", got) | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +func TestTheAgentReceivesThePastedTextWhereTheTokenStood(t *testing.T) { | ||
| 67 | + // This is the point: the token is for the box; the model gets the bytes. | ||
| 68 | + view, agent := mentioningView(t, nil) | ||
| 69 | + agent.handshake() | ||
| 70 | + waitFor(t, "ready", view.Session().Ready) | ||
| 71 | + | ||
| 72 | + typeInto(view, "here is the trace ") | ||
| 73 | + view.Paste(trace) | ||
| 74 | + typeInto(view, " what is failing?") | ||
| 75 | + view.Send() | ||
| 76 | + | ||
| 77 | + blocks := promptBlocks(t, agent.read()) | ||
| 78 | + if len(blocks) != 1 || blocks[0]["type"] != "text" { | ||
| 79 | + t.Fatalf("the prompt is %v, want one text block", blocks) | ||
| 80 | + } | ||
| 81 | + if got := blocks[0]["text"]; got != "here is the trace "+trace+" what is failing?" { | ||
| 82 | + t.Errorf("the agent received %q", got) | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + // The conversation keeps the token, so the exchange stays readable. | ||
| 86 | + if got := textOf(view.Session().Entries(), acp.EntryUser); !strings.Contains(got, "[Pasted #1 · 6 lines") || strings.Contains(got, "goroutine") { | ||
| 87 | + t.Errorf("the conversation says %q, want the token and not the trace", got) | ||
| 88 | + } | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +func TestAFileNamedInsideAPasteIsNotTakenForAMention(t *testing.T) { | ||
| 92 | + // A paste is sent byte for byte. An "@name" in a pasted log is characters, | ||
| 93 | + // not a request to attach the file. | ||
| 94 | + view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil }) | ||
| 95 | + agent.handshake() | ||
| 96 | + waitFor(t, "ready", view.Session().Ready) | ||
| 97 | + | ||
| 98 | + pasted := "see @internal/scanner.go for details\nand the next line" | ||
| 99 | + view.Paste(pasted) | ||
| 100 | + view.Send() | ||
| 101 | + | ||
| 102 | + blocks := promptBlocks(t, agent.read()) | ||
| 103 | + if len(blocks) != 1 || blocks[0]["type"] != "text" || blocks[0]["text"] != pasted { | ||
| 104 | + t.Errorf("the prompt is %v, want the pasted text alone, verbatim", blocks) | ||
| 105 | + } | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +func TestAMentionTypedBesideATokenStillWorks(t *testing.T) { | ||
| 109 | + view, agent := mentioningView(t, func(string) (string, error) { return "package internal\n", nil }) | ||
| 110 | + agent.handshake() | ||
| 111 | + waitFor(t, "ready", view.Session().Ready) | ||
| 112 | + | ||
| 113 | + typeInto(view, "compare @internal/scanner.go with ") | ||
| 114 | + view.Paste(trace) | ||
| 115 | + view.Send() | ||
| 116 | + | ||
| 117 | + blocks := promptBlocks(t, agent.read()) | ||
| 118 | + if len(blocks) != 3 || blocks[1]["type"] != "resource" { | ||
| 119 | + t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks) | ||
| 120 | + } | ||
| 121 | + if blocks[2]["text"] != " with "+trace { | ||
| 122 | + t.Errorf("block 2 = %q, want the words and the trace", blocks[2]["text"]) | ||
| 123 | + } | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +func TestBackspaceRemovesAWholeToken(t *testing.T) { | ||
| 127 | + // A token missing its last bracket stands for nothing, so the token is | ||
| 128 | + // one thing to the editing keys. | ||
| 129 | + view, agent := mentioningView(t, nil) | ||
| 130 | + agent.handshake() | ||
| 131 | + waitFor(t, "ready", view.Session().Ready) | ||
| 132 | + | ||
| 133 | + typeInto(view, "a ") | ||
| 134 | + view.Paste(trace) | ||
| 135 | + view.HandleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone)) | ||
| 136 | + | ||
| 137 | + if got := view.Input(); got != "a " { | ||
| 138 | + t.Errorf("Input() = %q after Backspace, want the token gone whole", got) | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + // And its text goes with it: nothing of the trace reaches the agent. | ||
| 142 | + typeInto(view, "b") | ||
| 143 | + view.Send() | ||
| 144 | + blocks := promptBlocks(t, agent.read()) | ||
| 145 | + if len(blocks) != 1 || blocks[0]["text"] != "a b" { | ||
| 146 | + t.Errorf("the agent received %v, want just %q", blocks, "a b") | ||
| 147 | + } | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +func TestDeleteAtTheStartOfATokenRemovesItWhole(t *testing.T) { | ||
| 151 | + view, _ := newView(t, 60, 12) | ||
| 152 | + view.Paste(trace) | ||
| 153 | + typeInto(view, "!") | ||
| 154 | + view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) | ||
| 155 | + view.HandleKey(tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone)) | ||
| 156 | + | ||
| 157 | + if got := view.Input(); got != "!" { | ||
| 158 | + t.Errorf("Input() = %q after Delete at the token's start", got) | ||
| 159 | + } | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +func TestTheArrowKeysStepOverATokenRatherThanInto(t *testing.T) { | ||
| 163 | + view, _ := newView(t, 60, 12) | ||
| 164 | + view.Paste(trace) | ||
| 165 | + typeInto(view, "x") | ||
| 166 | + | ||
| 167 | + view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // before x | ||
| 168 | + view.HandleKey(tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone)) // over the token | ||
| 169 | + typeInto(view, "y") | ||
| 170 | + view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) // over it again | ||
| 171 | + typeInto(view, "z") | ||
| 172 | + | ||
| 173 | + if got := view.Input(); got != "y[Pasted #1 · 6 lines · 151 chars]zx" { | ||
| 174 | + t.Errorf("Input() = %q", got) | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +func TestAMoveByRowThatLandsInsideATokenIsPushedToItsEdge(t *testing.T) { | ||
| 179 | + // Only ↑/↓ can land inside one; typing into the middle of a token would | ||
| 180 | + // break it into text that stands for nothing. | ||
| 181 | + view, _ := newView(t, 40, 12) // rows of 37 runes | ||
| 182 | + typeInto(view, strings.Repeat("a", 30)+" ") | ||
| 183 | + view.Paste(trace) // the 34-rune token wraps onto the second row | ||
| 184 | + view.HandleKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) | ||
| 185 | + view.HandleKey(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) | ||
| 186 | + view.HandleKey(tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone)) | ||
| 187 | + view.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) // column 1 of the token's row | ||
| 188 | + typeInto(view, "!") | ||
| 189 | + | ||
| 190 | + got := view.Input() | ||
| 191 | + if !strings.Contains(got, "![Pasted") && !strings.Contains(got, "chars]!") { | ||
| 192 | + t.Errorf("Input() = %q, want the ! at an edge of the token", got) | ||
| 193 | + } | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +func TestATokenIsDrawnInItsOwnColour(t *testing.T) { | ||
| 197 | + view, _ := newView(t, 60, 12) | ||
| 198 | + typeInto(view, "see ") | ||
| 199 | + view.Paste(trace) | ||
| 200 | + | ||
| 201 | + screen := tcell.NewSimulationScreen("UTF-8") | ||
| 202 | + if err := screen.Init(); err != nil { | ||
| 203 | + t.Fatal(err) | ||
| 204 | + } | ||
| 205 | + defer screen.Fini() | ||
| 206 | + screen.SetSize(60, 12) | ||
| 207 | + th, err := theme.Load(theme.DefaultName, "") | ||
| 208 | + if err != nil { | ||
| 209 | + t.Fatal(err) | ||
| 210 | + } | ||
| 211 | + view.Draw(ui.NewPainter(screen), th) | ||
| 212 | + screen.Show() | ||
| 213 | + | ||
| 214 | + cells, w, _ := screen.GetContents() | ||
| 215 | + row := 9 // the box's first row | ||
| 216 | + word, token := cells[row*w+2].Style, cells[row*w+2+len("see ")].Style | ||
| 217 | + if word == token { | ||
| 218 | + t.Error("the token is drawn in the same colour as the words around it") | ||
| 219 | + } | ||
| 220 | + if got := string(cells[row*w+2+len("see ")].Runes); got != "[" { | ||
| 221 | + t.Errorf("the token's first cell shows %q, want its bracket", got) | ||
| 222 | + } | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +func TestCtrlVPastesWhatTheEditorsClipboardHolds(t *testing.T) { | ||
| 226 | + view, _ := newView(t, 60, 12) | ||
| 227 | + view.OnPaste = func() string { return "one\ntwo\nthree" } | ||
| 228 | + | ||
| 229 | + view.HandleKey(tcell.NewEventKey(tcell.KeyCtrlV, 0, tcell.ModNone)) | ||
| 230 | + | ||
| 231 | + if got := view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { | ||
| 232 | + t.Errorf("Input() = %q after Ctrl-V", got) | ||
| 233 | + } | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +func TestShiftInsertPastesToo(t *testing.T) { | ||
| 237 | + view, _ := newView(t, 60, 12) | ||
| 238 | + view.OnPaste = func() string { return "short" } | ||
| 239 | + | ||
| 240 | + view.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) | ||
| 241 | + | ||
| 242 | + if got := view.Input(); got != "short" { | ||
| 243 | + t.Errorf("Input() = %q after Shift-Ins", got) | ||
| 244 | + } | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +func TestPasteNumbersRunOnAcrossPrompts(t *testing.T) { | ||
| 248 | + // "#1" in an earlier exchange is not the same thing as "#1" in this one. | ||
| 249 | + view, agent := mentioningView(t, nil) | ||
| 250 | + agent.handshake() | ||
| 251 | + waitFor(t, "ready", view.Session().Ready) | ||
| 252 | + | ||
| 253 | + view.Paste(trace) | ||
| 254 | + view.Send() | ||
| 255 | + agent.read() | ||
| 256 | + view.Paste(trace) | ||
| 257 | + | ||
| 258 | + if got := view.Input(); !strings.HasPrefix(got, "[Pasted #2 ") { | ||
| 259 | + t.Errorf("the second paste is %q, want #2", got) | ||
| 260 | + } | ||
| 261 | +} | ||
| 262 | + | ||
| 263 | +func TestAPasteGoesIntoTheBoxWhicheverPaneHadTheFocus(t *testing.T) { | ||
| 264 | + view, _ := newView(t, 60, 12) | ||
| 265 | + view.HandleKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) // to the conversation | ||
| 266 | + | ||
| 267 | + view.Paste("hello") | ||
| 268 | + typeInto(view, "!") | ||
| 269 | + | ||
| 270 | + if got := view.Input(); got != "hello!" { | ||
| 271 | + t.Errorf("Input() = %q, want the paste and the keystroke after it in the box", got) | ||
| 272 | + } | ||
| 273 | +} | ||
modified
acp/session.go +19 -5 | @@ -156,6 +156,7 @@ type Session struct { | ||
| 156 | 156 | // pending is a prompt typed before the handshake finished, held until it has. |
| 157 | 157 | type pending struct { |
| 158 | 158 | text string |
| 159 | + pastes []Paste | |
| 159 | 160 | mentions []Mention |
| 160 | 161 | } |
| 161 | 162 | |
| @@ -238,7 +239,7 @@ func (s *Session) handshake(cwd string) { | ||
| 238 | 239 | |
| 239 | 240 | s.wake() |
| 240 | 241 | for _, prompt := range queued { |
| 241 | - s.send(prompt.text, prompt.mentions) | |
| 242 | + s.send(prompt.text, prompt.pastes, prompt.mentions) | |
| 242 | 243 | } |
| 243 | 244 | } |
| 244 | 245 | |
| @@ -315,6 +316,19 @@ func (s *Session) newSession(ctx context.Context, cwd string) error { | ||
| 315 | 316 | // session.Prompt("what does buildMenus do?") |
| 316 | 317 | // session.Prompt("explain @app/menus.go", acp.Mention{Name: "app/menus.go", Path: "/src/p/app/menus.go"}) |
| 317 | 318 | func (s *Session) Prompt(text string, mentions ...Mention) { |
| 319 | + s.PromptWith(text, nil, mentions...) | |
| 320 | +} | |
| 321 | + | |
| 322 | +// PromptWith is Prompt for a text in which pasted passages stand as tokens. | |
| 323 | +// | |
| 324 | +// Each paste's Token is replaced by its Text in what the agent receives — | |
| 325 | +// byte for byte, and only inside the text blocks, so a mention is never read | |
| 326 | +// out of a paste. The conversation keeps the text with the tokens in it: that | |
| 327 | +// is what makes the exchange readable afterwards, which is half the point of | |
| 328 | +// keeping a paste aside at all. | |
| 329 | +// | |
| 330 | +// session.PromptWith("here is the trace "+paste.Token+" what is failing?", []acp.Paste{paste}) | |
| 331 | +func (s *Session) PromptWith(text string, pastes []Paste, mentions ...Mention) { | |
| 318 | 332 | if text == "" { |
| 319 | 333 | return |
| 320 | 334 | } |
| @@ -323,7 +337,7 @@ func (s *Session) Prompt(text string, mentions ...Mention) { | ||
| 323 | 337 | s.transcript.AddUser("You", text) |
| 324 | 338 | ready, failed := s.ready, s.failed |
| 325 | 339 | if !ready && failed == nil { |
| 326 | - s.queued = append(s.queued, pending{text: text, mentions: mentions}) | |
| 340 | + s.queued = append(s.queued, pending{text: text, pastes: pastes, mentions: mentions}) | |
| 327 | 341 | } |
| 328 | 342 | s.mu.Unlock() |
| 329 | 343 | |
| @@ -332,12 +346,12 @@ func (s *Session) Prompt(text string, mentions ...Mention) { | ||
| 332 | 346 | case failed != nil: |
| 333 | 347 | s.note(fmt.Sprintf("The agent is not running: %v", failed)) |
| 334 | 348 | case ready: |
| 335 | - go s.send(text, mentions) | |
| 349 | + go s.send(text, pastes, mentions) | |
| 336 | 350 | } |
| 337 | 351 | } |
| 338 | 352 | |
| 339 | 353 | // send runs one turn and records how it ended. |
| 340 | -func (s *Session) send(text string, mentions []Mention) { | |
| 354 | +func (s *Session) send(text string, pastes []Paste, mentions []Mention) { | |
| 341 | 355 | s.mu.Lock() |
| 342 | 356 | id, ready, embeds := s.sessionID, s.ready, s.embeds |
| 343 | 357 | s.turn = true |
| @@ -348,7 +362,7 @@ func (s *Session) send(text string, mentions []Mention) { | ||
| 348 | 362 | } |
| 349 | 363 | s.wake() |
| 350 | 364 | |
| 351 | - params := PromptParams{SessionID: id, Prompt: blocksFor(text, mentions, embeds, s.readFile)} | |
| 365 | + params := PromptParams{SessionID: id, Prompt: expandPastes(blocksFor(text, mentions, embeds, s.readFile), pastes)} | |
| 352 | 366 | |
| 353 | 367 | // No timeout: a turn takes as long as the model takes, and cutting one off |
| 354 | 368 | // after an arbitrary number of seconds would look exactly like a refusal. |
| @@ -156,6 +156,7 @@ type Session struct { | |||
| 156 | // pending is a prompt typed before the handshake finished, held until it has. | 156 | // pending is a prompt typed before the handshake finished, held until it has. |
| 157 | type pending struct { | 157 | type pending struct { |
| 158 | text string | 158 | text string |
| 159 | + pastes []Paste | ||
| 159 | mentions []Mention | 160 | mentions []Mention |
| 160 | } | 161 | } |
| 161 | 162 | ||
| @@ -238,7 +239,7 @@ func (s *Session) handshake(cwd string) { | |||
| 238 | 239 | ||
| 239 | s.wake() | 240 | s.wake() |
| 240 | for _, prompt := range queued { | 241 | for _, prompt := range queued { |
| 241 | - s.send(prompt.text, prompt.mentions) | 242 | + s.send(prompt.text, prompt.pastes, prompt.mentions) |
| 242 | } | 243 | } |
| 243 | } | 244 | } |
| 244 | 245 | ||
| @@ -315,6 +316,19 @@ func (s *Session) newSession(ctx context.Context, cwd string) error { | |||
| 315 | // session.Prompt("what does buildMenus do?") | 316 | // session.Prompt("what does buildMenus do?") |
| 316 | // session.Prompt("explain @app/menus.go", acp.Mention{Name: "app/menus.go", Path: "/src/p/app/menus.go"}) | 317 | // session.Prompt("explain @app/menus.go", acp.Mention{Name: "app/menus.go", Path: "/src/p/app/menus.go"}) |
| 317 | func (s *Session) Prompt(text string, mentions ...Mention) { | 318 | func (s *Session) Prompt(text string, mentions ...Mention) { |
| 319 | + s.PromptWith(text, nil, mentions...) | ||
| 320 | +} | ||
| 321 | + | ||
| 322 | +// PromptWith is Prompt for a text in which pasted passages stand as tokens. | ||
| 323 | +// | ||
| 324 | +// Each paste's Token is replaced by its Text in what the agent receives — | ||
| 325 | +// byte for byte, and only inside the text blocks, so a mention is never read | ||
| 326 | +// out of a paste. The conversation keeps the text with the tokens in it: that | ||
| 327 | +// is what makes the exchange readable afterwards, which is half the point of | ||
| 328 | +// keeping a paste aside at all. | ||
| 329 | +// | ||
| 330 | +// session.PromptWith("here is the trace "+paste.Token+" what is failing?", []acp.Paste{paste}) | ||
| 331 | +func (s *Session) PromptWith(text string, pastes []Paste, mentions ...Mention) { | ||
| 318 | if text == "" { | 332 | if text == "" { |
| 319 | return | 333 | return |
| 320 | } | 334 | } |
| @@ -323,7 +337,7 @@ func (s *Session) Prompt(text string, mentions ...Mention) { | |||
| 323 | s.transcript.AddUser("You", text) | 337 | s.transcript.AddUser("You", text) |
| 324 | ready, failed := s.ready, s.failed | 338 | ready, failed := s.ready, s.failed |
| 325 | if !ready && failed == nil { | 339 | if !ready && failed == nil { |
| 326 | - s.queued = append(s.queued, pending{text: text, mentions: mentions}) | 340 | + s.queued = append(s.queued, pending{text: text, pastes: pastes, mentions: mentions}) |
| 327 | } | 341 | } |
| 328 | s.mu.Unlock() | 342 | s.mu.Unlock() |
| 329 | 343 | ||
| @@ -332,12 +346,12 @@ func (s *Session) Prompt(text string, mentions ...Mention) { | |||
| 332 | case failed != nil: | 346 | case failed != nil: |
| 333 | s.note(fmt.Sprintf("The agent is not running: %v", failed)) | 347 | s.note(fmt.Sprintf("The agent is not running: %v", failed)) |
| 334 | case ready: | 348 | case ready: |
| 335 | - go s.send(text, mentions) | 349 | + go s.send(text, pastes, mentions) |
| 336 | } | 350 | } |
| 337 | } | 351 | } |
| 338 | 352 | ||
| 339 | // send runs one turn and records how it ended. | 353 | // send runs one turn and records how it ended. |
| 340 | -func (s *Session) send(text string, mentions []Mention) { | 354 | +func (s *Session) send(text string, pastes []Paste, mentions []Mention) { |
| 341 | s.mu.Lock() | 355 | s.mu.Lock() |
| 342 | id, ready, embeds := s.sessionID, s.ready, s.embeds | 356 | id, ready, embeds := s.sessionID, s.ready, s.embeds |
| 343 | s.turn = true | 357 | s.turn = true |
| @@ -348,7 +362,7 @@ func (s *Session) send(text string, mentions []Mention) { | |||
| 348 | } | 362 | } |
| 349 | s.wake() | 363 | s.wake() |
| 350 | 364 | ||
| 351 | - params := PromptParams{SessionID: id, Prompt: blocksFor(text, mentions, embeds, s.readFile)} | 365 | + params := PromptParams{SessionID: id, Prompt: expandPastes(blocksFor(text, mentions, embeds, s.readFile), pastes)} |
| 352 | 366 | ||
| 353 | // No timeout: a turn takes as long as the model takes, and cutting one off | 367 | // No timeout: a turn takes as long as the model takes, and cutting one off |
| 354 | // after an arbitrary number of seconds would look exactly like a refusal. | 368 | // after an arbitrary number of seconds would look exactly like a refusal. |
modified
acp/view.go +19 -4 | @@ -10,7 +10,7 @@ import ( | ||
| 10 | 10 | |
| 11 | 11 | // InputHeight is how many rows the box you type into takes, not counting the |
| 12 | 12 | // rule above it. Three is enough for a sentence that wrapped once, and leaves |
| 13 | -// the conversation the rest. | |
| 13 | +// the conversation the rest; a longer prompt scrolls inside the box. | |
| 14 | 14 | const InputHeight = 3 |
| 15 | 15 | |
| 16 | 16 | // View is the inside of an agent window: the conversation above, a box to type |
| @@ -27,11 +27,18 @@ type View struct { | ||
| 27 | 27 | |
| 28 | 28 | session *Session |
| 29 | 29 | |
| 30 | - // input is what has been typed but not sent, as lines. | |
| 30 | + // input is what has been typed but not sent, as lines — the ones Alt-Enter | |
| 31 | + // makes. How they wrap to the pane is worked out when drawing (input.go). | |
| 31 | 32 | input []string |
| 32 | 33 | cursor int // which line the cursor is on |
| 33 | 34 | column int // and where in it, in runes |
| 34 | 35 | |
| 36 | + // pastes is the text kept aside for each token in the box (paste.go), and | |
| 37 | + // pasteCount numbers them for the life of the window, so that "#1" in an | |
| 38 | + // earlier exchange is not the same thing as "#1" in this one. | |
| 39 | + pastes []Paste | |
| 40 | + pasteCount int | |
| 41 | + | |
| 35 | 42 | // scroll is how many laid-out lines are hidden above the top of the |
| 36 | 43 | // conversation, and follow says to keep the last line in sight as the |
| 37 | 44 | // agent writes. |
| @@ -64,6 +71,12 @@ type View struct { | ||
| 64 | 71 | // window rather than to the window. |
| 65 | 72 | OnCopy func(text string) |
| 66 | 73 | |
| 74 | + // OnPaste returns what the editor's clipboard holds, for Ctrl-V and | |
| 75 | + // Shift-Ins. Same shape as OnCopy, for the same reasons; nil means those | |
| 76 | + // keys paste nothing. A paste from outside the editor does not come this | |
| 77 | + // way: the terminal brackets it and the editor hands it to Paste whole. | |
| 78 | + OnPaste func() string | |
| 79 | + | |
| 67 | 80 | // Files returns the project's files, for the picker "@" opens and for |
| 68 | 81 | // telling a mention from an email address when the prompt is sent. It is |
| 69 | 82 | // the editor's to supply because the editor knows where the project is; |
| @@ -137,15 +150,17 @@ func (v *View) SetInput(text string) { | ||
| 137 | 150 | // |
| 138 | 151 | // It does nothing with an empty box: Enter on a blank line is somebody |
| 139 | 152 | // thinking, not a turn worth spending. A file named with "@" goes along as a |
| 140 | -// mention, so the agent is given the file and not merely its name. | |
| 153 | +// mention, so the agent is given the file and not merely its name; a paste | |
| 154 | +// standing in the box as a token goes along as its text. | |
| 141 | 155 | func (v *View) Send() { |
| 142 | 156 | text := strings.TrimSpace(v.Input()) |
| 143 | 157 | if text == "" { |
| 144 | 158 | return |
| 145 | 159 | } |
| 146 | 160 | |
| 147 | - v.session.Prompt(text, v.mentions(text)...) | |
| 161 | + v.session.PromptWith(text, v.pastesIn(text), v.mentions(text)...) | |
| 148 | 162 | v.SetInput("") |
| 163 | + v.pastes = nil | |
| 149 | 164 | v.follow = true |
| 150 | 165 | } |
| 151 | 166 | |
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | // InputHeight is how many rows the box you type into takes, not counting the | 11 | // InputHeight is how many rows the box you type into takes, not counting the |
| 12 | // rule above it. Three is enough for a sentence that wrapped once, and leaves | 12 | // rule above it. Three is enough for a sentence that wrapped once, and leaves |
| 13 | -// the conversation the rest. | 13 | +// the conversation the rest; a longer prompt scrolls inside the box. |
| 14 | const InputHeight = 3 | 14 | const InputHeight = 3 |
| 15 | 15 | ||
| 16 | // View is the inside of an agent window: the conversation above, a box to type | 16 | // View is the inside of an agent window: the conversation above, a box to type |
| @@ -27,11 +27,18 @@ type View struct { | |||
| 27 | 27 | ||
| 28 | session *Session | 28 | session *Session |
| 29 | 29 | ||
| 30 | - // input is what has been typed but not sent, as lines. | 30 | + // input is what has been typed but not sent, as lines — the ones Alt-Enter |
| 31 | + // makes. How they wrap to the pane is worked out when drawing (input.go). | ||
| 31 | input []string | 32 | input []string |
| 32 | cursor int // which line the cursor is on | 33 | cursor int // which line the cursor is on |
| 33 | column int // and where in it, in runes | 34 | column int // and where in it, in runes |
| 34 | 35 | ||
| 36 | + // pastes is the text kept aside for each token in the box (paste.go), and | ||
| 37 | + // pasteCount numbers them for the life of the window, so that "#1" in an | ||
| 38 | + // earlier exchange is not the same thing as "#1" in this one. | ||
| 39 | + pastes []Paste | ||
| 40 | + pasteCount int | ||
| 41 | + | ||
| 35 | // scroll is how many laid-out lines are hidden above the top of the | 42 | // scroll is how many laid-out lines are hidden above the top of the |
| 36 | // conversation, and follow says to keep the last line in sight as the | 43 | // conversation, and follow says to keep the last line in sight as the |
| 37 | // agent writes. | 44 | // agent writes. |
| @@ -64,6 +71,12 @@ type View struct { | |||
| 64 | // window rather than to the window. | 71 | // window rather than to the window. |
| 65 | OnCopy func(text string) | 72 | OnCopy func(text string) |
| 66 | 73 | ||
| 74 | + // OnPaste returns what the editor's clipboard holds, for Ctrl-V and | ||
| 75 | + // Shift-Ins. Same shape as OnCopy, for the same reasons; nil means those | ||
| 76 | + // keys paste nothing. A paste from outside the editor does not come this | ||
| 77 | + // way: the terminal brackets it and the editor hands it to Paste whole. | ||
| 78 | + OnPaste func() string | ||
| 79 | + | ||
| 67 | // Files returns the project's files, for the picker "@" opens and for | 80 | // Files returns the project's files, for the picker "@" opens and for |
| 68 | // telling a mention from an email address when the prompt is sent. It is | 81 | // telling a mention from an email address when the prompt is sent. It is |
| 69 | // the editor's to supply because the editor knows where the project is; | 82 | // the editor's to supply because the editor knows where the project is; |
| @@ -137,15 +150,17 @@ func (v *View) SetInput(text string) { | |||
| 137 | // | 150 | // |
| 138 | // It does nothing with an empty box: Enter on a blank line is somebody | 151 | // It does nothing with an empty box: Enter on a blank line is somebody |
| 139 | // thinking, not a turn worth spending. A file named with "@" goes along as a | 152 | // thinking, not a turn worth spending. A file named with "@" goes along as a |
| 140 | -// mention, so the agent is given the file and not merely its name. | 153 | +// mention, so the agent is given the file and not merely its name; a paste |
| 154 | +// standing in the box as a token goes along as its text. | ||
| 141 | func (v *View) Send() { | 155 | func (v *View) Send() { |
| 142 | text := strings.TrimSpace(v.Input()) | 156 | text := strings.TrimSpace(v.Input()) |
| 143 | if text == "" { | 157 | if text == "" { |
| 144 | return | 158 | return |
| 145 | } | 159 | } |
| 146 | 160 | ||
| 147 | - v.session.Prompt(text, v.mentions(text)...) | 161 | + v.session.PromptWith(text, v.pastesIn(text), v.mentions(text)...) |
| 148 | v.SetInput("") | 162 | v.SetInput("") |
| 163 | + v.pastes = nil | ||
| 149 | v.follow = true | 164 | v.follow = true |
| 150 | } | 165 | } |
| 151 | 166 | ||
modified
acp/view_draw.go +41 -9 | @@ -200,7 +200,13 @@ func (v *View) pickerLabel() string { | ||
| 200 | 200 | return label |
| 201 | 201 | } |
| 202 | 202 | |
| 203 | -// drawInput paints what has been typed, and the cursor when it has the focus. | |
| 203 | +// drawInput paints what has been typed, wrapped to the pane, and the cursor | |
| 204 | +// when the box has the focus. | |
| 205 | +// | |
| 206 | +// The rows shown are the ones that end with the cursor's: a prompt longer | |
| 207 | +// than the box scrolls up under the rule as you type, and the prompt marker | |
| 208 | +// goes with its first row rather than standing beside whatever row happens | |
| 209 | +// to be at the top. | |
| 204 | 210 | func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) { |
| 205 | 211 | size := p.Size() |
| 206 | 212 | top := v.transcriptHeight() + 1 |
| @@ -209,19 +215,45 @@ func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) { | ||
| 209 | 215 | return |
| 210 | 216 | } |
| 211 | 217 | |
| 212 | - prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body)) | |
| 213 | - p.Text(0, top, ">", prompt) | |
| 218 | + width := v.inputWidth() | |
| 219 | + rows := v.inputRows(width) | |
| 220 | + row, column := v.inputCursor(rows) | |
| 221 | + from := max(row-height+1, 0) | |
| 214 | 222 | |
| 215 | - from := max(v.cursor-height+1, 0) | |
| 216 | - for row := 0; row < height; row++ { | |
| 217 | - at := from + row | |
| 218 | - if at >= len(v.input) { | |
| 223 | + if from == 0 { | |
| 224 | + prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body)) | |
| 225 | + p.Text(0, top, ">", prompt) | |
| 226 | + } | |
| 227 | + for y := 0; y < height; y++ { | |
| 228 | + at := from + y | |
| 229 | + if at >= len(rows) { | |
| 219 | 230 | break |
| 220 | 231 | } |
| 221 | - p.TextLimited(2, top+row, max(size.W-2, 0), v.input[at], body) | |
| 232 | + v.drawInputRow(p, th, top+y, rows[at], body) | |
| 222 | 233 | } |
| 223 | 234 | |
| 224 | 235 | if v.focused && v.onInput { |
| 225 | - p.ShowCursor(2+v.column, top+v.cursor-from) | |
| 236 | + p.ShowCursor(2+column, top+row-from) | |
| 237 | + } | |
| 238 | +} | |
| 239 | + | |
| 240 | +// drawInputRow paints one row of the box, rune by rune, a paste token in the | |
| 241 | +// colour tool calls take — it is a thing the agent will be handed, not words | |
| 242 | +// — and everything else as body text. | |
| 243 | +// | |
| 244 | +// A row may hold one rune more than the width, the space it broke at; the | |
| 245 | +// loop draws through it and the frame clips it, which keeps the rule "a row | |
| 246 | +// is its runes" in one place. | |
| 247 | +func (v *View) drawInputRow(p *ui.Painter, th *theme.Theme, y int, row inputRow, body tcell.Style) { | |
| 248 | + runes := []rune(v.input[row.line]) | |
| 249 | + spans := v.tokensIn(v.input[row.line]) | |
| 250 | + token := th.Style(theme.KeySyntaxType).Background(backgroundOf(body)) | |
| 251 | + | |
| 252 | + for at := row.start; at < row.end; at++ { | |
| 253 | + style := body | |
| 254 | + if inToken(spans, at) { | |
| 255 | + style = token | |
| 256 | + } | |
| 257 | + p.SetCell(2+at-row.start, y, runes[at], style) | |
| 226 | 258 | } |
| 227 | 259 | } |
| @@ -200,7 +200,13 @@ func (v *View) pickerLabel() string { | |||
| 200 | return label | 200 | return label |
| 201 | } | 201 | } |
| 202 | 202 | ||
| 203 | -// drawInput paints what has been typed, and the cursor when it has the focus. | 203 | +// drawInput paints what has been typed, wrapped to the pane, and the cursor |
| 204 | +// when the box has the focus. | ||
| 205 | +// | ||
| 206 | +// The rows shown are the ones that end with the cursor's: a prompt longer | ||
| 207 | +// than the box scrolls up under the rule as you type, and the prompt marker | ||
| 208 | +// goes with its first row rather than standing beside whatever row happens | ||
| 209 | +// to be at the top. | ||
| 204 | func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) { | 210 | func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) { |
| 205 | size := p.Size() | 211 | size := p.Size() |
| 206 | top := v.transcriptHeight() + 1 | 212 | top := v.transcriptHeight() + 1 |
| @@ -209,19 +215,45 @@ func (v *View) drawInput(p *ui.Painter, th *theme.Theme, body tcell.Style) { | |||
| 209 | return | 215 | return |
| 210 | } | 216 | } |
| 211 | 217 | ||
| 212 | - prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body)) | 218 | + width := v.inputWidth() |
| 213 | - p.Text(0, top, ">", prompt) | 219 | + rows := v.inputRows(width) |
| 220 | + row, column := v.inputCursor(rows) | ||
| 221 | + from := max(row-height+1, 0) | ||
| 214 | 222 | ||
| 215 | - from := max(v.cursor-height+1, 0) | 223 | + if from == 0 { |
| 216 | - for row := 0; row < height; row++ { | 224 | + prompt := th.Style(theme.KeySyntaxKeyword).Background(backgroundOf(body)) |
| 217 | - at := from + row | 225 | + p.Text(0, top, ">", prompt) |
| 218 | - if at >= len(v.input) { | 226 | + } |
| 227 | + for y := 0; y < height; y++ { | ||
| 228 | + at := from + y | ||
| 229 | + if at >= len(rows) { | ||
| 219 | break | 230 | break |
| 220 | } | 231 | } |
| 221 | - p.TextLimited(2, top+row, max(size.W-2, 0), v.input[at], body) | 232 | + v.drawInputRow(p, th, top+y, rows[at], body) |
| 222 | } | 233 | } |
| 223 | 234 | ||
| 224 | if v.focused && v.onInput { | 235 | if v.focused && v.onInput { |
| 225 | - p.ShowCursor(2+v.column, top+v.cursor-from) | 236 | + p.ShowCursor(2+column, top+row-from) |
| 237 | + } | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +// drawInputRow paints one row of the box, rune by rune, a paste token in the | ||
| 241 | +// colour tool calls take — it is a thing the agent will be handed, not words | ||
| 242 | +// — and everything else as body text. | ||
| 243 | +// | ||
| 244 | +// A row may hold one rune more than the width, the space it broke at; the | ||
| 245 | +// loop draws through it and the frame clips it, which keeps the rule "a row | ||
| 246 | +// is its runes" in one place. | ||
| 247 | +func (v *View) drawInputRow(p *ui.Painter, th *theme.Theme, y int, row inputRow, body tcell.Style) { | ||
| 248 | + runes := []rune(v.input[row.line]) | ||
| 249 | + spans := v.tokensIn(v.input[row.line]) | ||
| 250 | + token := th.Style(theme.KeySyntaxType).Background(backgroundOf(body)) | ||
| 251 | + | ||
| 252 | + for at := row.start; at < row.end; at++ { | ||
| 253 | + style := body | ||
| 254 | + if inToken(spans, at) { | ||
| 255 | + style = token | ||
| 256 | + } | ||
| 257 | + p.SetCell(2+at-row.start, y, runes[at], style) | ||
| 226 | } | 258 | } |
| 227 | } | 259 | } |
modified
acp/view_events.go +28 -16 | @@ -40,6 +40,8 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) (handled, claimed bool) { | ||
| 40 | 40 | v.onInput = !v.onInput |
| 41 | 41 | case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev): |
| 42 | 42 | return v.copySelection(), true |
| 43 | + case ev.Key() == tcell.KeyCtrlV, isPasteKey(ev): | |
| 44 | + return v.pasteClipboard(), true | |
| 43 | 45 | case ev.Key() == tcell.KeyEscape: |
| 44 | 46 | return v.clearOrCancel(), true |
| 45 | 47 | case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0: |
| @@ -102,17 +104,9 @@ func (v *View) handleInputKey(ev *tcell.EventKey) bool { | ||
| 102 | 104 | case tcell.KeyEnd: |
| 103 | 105 | v.column = len(v.runes()) |
| 104 | 106 | case tcell.KeyUp: |
| 105 | - if v.cursor == 0 { | |
| 106 | - return false // let it move through the conversation instead | |
| 107 | - } | |
| 108 | - v.cursor-- | |
| 109 | - v.clampColumn() | |
| 107 | + return v.moveInputRow(-1) // off the top, it moves through the conversation instead | |
| 110 | 108 | case tcell.KeyDown: |
| 111 | - if v.cursor >= len(v.input)-1 { | |
| 112 | - return false | |
| 113 | - } | |
| 114 | - v.cursor++ | |
| 115 | - v.clampColumn() | |
| 109 | + return v.moveInputRow(1) | |
| 116 | 110 | default: |
| 117 | 111 | return false |
| 118 | 112 | } |
| @@ -221,7 +215,13 @@ func (v *View) insertNewline() { | ||
| 221 | 215 | } |
| 222 | 216 | |
| 223 | 217 | // backspace removes the rune before the cursor, joining lines at a line start. |
| 218 | +// Before a paste token it removes the whole token: the token is one thing, | |
| 219 | +// and a token missing its last bracket stands for nothing. | |
| 224 | 220 | func (v *View) backspace() { |
| 221 | + if span, ok := v.tokenEndingAt(v.column); ok { | |
| 222 | + v.removeSpan(span) | |
| 223 | + return | |
| 224 | + } | |
| 225 | 225 | if v.column > 0 { |
| 226 | 226 | runes := v.runes() |
| 227 | 227 | v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...)) |
| @@ -239,8 +239,13 @@ func (v *View) backspace() { | ||
| 239 | 239 | v.cursor-- |
| 240 | 240 | } |
| 241 | 241 | |
| 242 | -// delete removes the rune under the cursor. | |
| 242 | +// delete removes the rune under the cursor — or the whole paste token that | |
| 243 | +// starts there, for the reason backspace gives. | |
| 243 | 244 | func (v *View) delete() { |
| 245 | + if span, ok := v.tokenStartingAt(v.column); ok { | |
| 246 | + v.removeSpan(span) | |
| 247 | + return | |
| 248 | + } | |
| 244 | 249 | runes := v.runes() |
| 245 | 250 | if v.column >= len(runes) { |
| 246 | 251 | return |
| @@ -248,8 +253,13 @@ func (v *View) delete() { | ||
| 248 | 253 | v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...)) |
| 249 | 254 | } |
| 250 | 255 | |
| 251 | -// moveLeft moves back one rune, onto the end of the line above at a line start. | |
| 256 | +// moveLeft moves back one rune — over a whole paste token — onto the end of | |
| 257 | +// the line above at a line start. | |
| 252 | 258 | func (v *View) moveLeft() { |
| 259 | + if span, ok := v.tokenEndingAt(v.column); ok { | |
| 260 | + v.column = span.start | |
| 261 | + return | |
| 262 | + } | |
| 253 | 263 | switch { |
| 254 | 264 | case v.column > 0: |
| 255 | 265 | v.column-- |
| @@ -259,8 +269,13 @@ func (v *View) moveLeft() { | ||
| 259 | 269 | } |
| 260 | 270 | } |
| 261 | 271 | |
| 262 | -// moveRight moves on one rune, onto the start of the next line at a line end. | |
| 272 | +// moveRight moves on one rune — over a whole paste token — onto the start of | |
| 273 | +// the next line at a line end. | |
| 263 | 274 | func (v *View) moveRight() { |
| 275 | + if span, ok := v.tokenStartingAt(v.column); ok { | |
| 276 | + v.column = span.end | |
| 277 | + return | |
| 278 | + } | |
| 264 | 279 | switch { |
| 265 | 280 | case v.column < len(v.runes()): |
| 266 | 281 | v.column++ |
| @@ -278,9 +293,6 @@ func (v *View) runes() []rune { | ||
| 278 | 293 | return []rune(v.input[v.cursor]) |
| 279 | 294 | } |
| 280 | 295 | |
| 281 | -// clampColumn keeps the cursor inside the line it moved onto. | |
| 282 | -func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) } | |
| 283 | - | |
| 284 | 296 | // scrollBy moves through the conversation, and stops following the end as soon |
| 285 | 297 | // as somebody scrolls up — reading what happened is not interrupted by the |
| 286 | 298 | // agent still writing. |
| @@ -40,6 +40,8 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) (handled, claimed bool) { | |||
| 40 | v.onInput = !v.onInput | 40 | v.onInput = !v.onInput |
| 41 | case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev): | 41 | case ev.Key() == tcell.KeyCtrlC, isCopyKey(ev): |
| 42 | return v.copySelection(), true | 42 | return v.copySelection(), true |
| 43 | + case ev.Key() == tcell.KeyCtrlV, isPasteKey(ev): | ||
| 44 | + return v.pasteClipboard(), true | ||
| 43 | case ev.Key() == tcell.KeyEscape: | 45 | case ev.Key() == tcell.KeyEscape: |
| 44 | return v.clearOrCancel(), true | 46 | return v.clearOrCancel(), true |
| 45 | case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0: | 47 | case ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0: |
| @@ -102,17 +104,9 @@ func (v *View) handleInputKey(ev *tcell.EventKey) bool { | |||
| 102 | case tcell.KeyEnd: | 104 | case tcell.KeyEnd: |
| 103 | v.column = len(v.runes()) | 105 | v.column = len(v.runes()) |
| 104 | case tcell.KeyUp: | 106 | case tcell.KeyUp: |
| 105 | - if v.cursor == 0 { | 107 | + return v.moveInputRow(-1) // off the top, it moves through the conversation instead |
| 106 | - return false // let it move through the conversation instead | ||
| 107 | - } | ||
| 108 | - v.cursor-- | ||
| 109 | - v.clampColumn() | ||
| 110 | case tcell.KeyDown: | 108 | case tcell.KeyDown: |
| 111 | - if v.cursor >= len(v.input)-1 { | 109 | + return v.moveInputRow(1) |
| 112 | - return false | ||
| 113 | - } | ||
| 114 | - v.cursor++ | ||
| 115 | - v.clampColumn() | ||
| 116 | default: | 110 | default: |
| 117 | return false | 111 | return false |
| 118 | } | 112 | } |
| @@ -221,7 +215,13 @@ func (v *View) insertNewline() { | |||
| 221 | } | 215 | } |
| 222 | 216 | ||
| 223 | // backspace removes the rune before the cursor, joining lines at a line start. | 217 | // backspace removes the rune before the cursor, joining lines at a line start. |
| 218 | +// Before a paste token it removes the whole token: the token is one thing, | ||
| 219 | +// and a token missing its last bracket stands for nothing. | ||
| 224 | func (v *View) backspace() { | 220 | func (v *View) backspace() { |
| 221 | + if span, ok := v.tokenEndingAt(v.column); ok { | ||
| 222 | + v.removeSpan(span) | ||
| 223 | + return | ||
| 224 | + } | ||
| 225 | if v.column > 0 { | 225 | if v.column > 0 { |
| 226 | runes := v.runes() | 226 | runes := v.runes() |
| 227 | v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...)) | 227 | v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...)) |
| @@ -239,8 +239,13 @@ func (v *View) backspace() { | |||
| 239 | v.cursor-- | 239 | v.cursor-- |
| 240 | } | 240 | } |
| 241 | 241 | ||
| 242 | -// delete removes the rune under the cursor. | 242 | +// delete removes the rune under the cursor — or the whole paste token that |
| 243 | +// starts there, for the reason backspace gives. | ||
| 243 | func (v *View) delete() { | 244 | func (v *View) delete() { |
| 245 | + if span, ok := v.tokenStartingAt(v.column); ok { | ||
| 246 | + v.removeSpan(span) | ||
| 247 | + return | ||
| 248 | + } | ||
| 244 | runes := v.runes() | 249 | runes := v.runes() |
| 245 | if v.column >= len(runes) { | 250 | if v.column >= len(runes) { |
| 246 | return | 251 | return |
| @@ -248,8 +253,13 @@ func (v *View) delete() { | |||
| 248 | v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...)) | 253 | v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...)) |
| 249 | } | 254 | } |
| 250 | 255 | ||
| 251 | -// moveLeft moves back one rune, onto the end of the line above at a line start. | 256 | +// moveLeft moves back one rune — over a whole paste token — onto the end of |
| 257 | +// the line above at a line start. | ||
| 252 | func (v *View) moveLeft() { | 258 | func (v *View) moveLeft() { |
| 259 | + if span, ok := v.tokenEndingAt(v.column); ok { | ||
| 260 | + v.column = span.start | ||
| 261 | + return | ||
| 262 | + } | ||
| 253 | switch { | 263 | switch { |
| 254 | case v.column > 0: | 264 | case v.column > 0: |
| 255 | v.column-- | 265 | v.column-- |
| @@ -259,8 +269,13 @@ func (v *View) moveLeft() { | |||
| 259 | } | 269 | } |
| 260 | } | 270 | } |
| 261 | 271 | ||
| 262 | -// moveRight moves on one rune, onto the start of the next line at a line end. | 272 | +// moveRight moves on one rune — over a whole paste token — onto the start of |
| 273 | +// the next line at a line end. | ||
| 263 | func (v *View) moveRight() { | 274 | func (v *View) moveRight() { |
| 275 | + if span, ok := v.tokenStartingAt(v.column); ok { | ||
| 276 | + v.column = span.end | ||
| 277 | + return | ||
| 278 | + } | ||
| 264 | switch { | 279 | switch { |
| 265 | case v.column < len(v.runes()): | 280 | case v.column < len(v.runes()): |
| 266 | v.column++ | 281 | v.column++ |
| @@ -278,9 +293,6 @@ func (v *View) runes() []rune { | |||
| 278 | return []rune(v.input[v.cursor]) | 293 | return []rune(v.input[v.cursor]) |
| 279 | } | 294 | } |
| 280 | 295 | ||
| 281 | -// clampColumn keeps the cursor inside the line it moved onto. | ||
| 282 | -func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) } | ||
| 283 | - | ||
| 284 | // scrollBy moves through the conversation, and stops following the end as soon | 296 | // scrollBy moves through the conversation, and stops following the end as soon |
| 285 | // as somebody scrolls up — reading what happened is not interrupted by the | 297 | // as somebody scrolls up — reading what happened is not interrupted by the |
| 286 | // agent still writing. | 298 | // agent still writing. |
modified
app/README.md +9 -1 | @@ -82,7 +82,15 @@ The popup is refreshed from `tick`, not from the reading goroutine, which may no | ||
| 82 | 82 | |
| 83 | 83 | The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work. |
| 84 | 84 | |
| 85 | -`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing. | |
| 85 | +**Anything else rewriting a file is noticed the same way, by stamp.** `reloadChangedFiles` (`watch.go`) keeps what each open file looked like on disk — size and modification time, `fileStamp`, the same guard `refreshToolMenus` uses — by canonical path in `a.fileStamps`, and on every turn of the loop re-reads the ones whose stamp moved, through the `reloadFromDisk` the tools reload shares (so telling the language server, added for this, could not go missing from the other). The editor's own writes re-stamp in `openBuffer`, `afterSave` and the reload itself, so a save does not come round as a reload of the text just written. A modified buffer is kept and the status bar says so once. A file that has gone is left alone, silently: some programs replace a file by deleting it and writing it again, and a stat between the two sees nothing. | |
| 86 | + | |
| 87 | +The loop sits in `PollEvent` while the user is idle, which is exactly when another program is most likely to be writing — so a `fileWatch` goroutine, started by `Run` and only by `Run`, stats the same stamps once a second and posts a wake when any differs. It is a nudge and nothing more: the loop decides for itself, so a dropped wake costs a late reload, never a lost one, and an idle editor with unchanged files is never woken. Tests set `a.fileWatchInterval` short and watch the simulation screen for the interrupt event. | |
| 88 | + | |
| 89 | +`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `reloadChangedFiles`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing. | |
| 90 | + | |
| 91 | +## A paste from outside | |
| 92 | + | |
| 93 | +`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, and the agent view reads the editor's clipboard through `OnPaste` for its own Ctrl-V. | |
| 86 | 94 | |
| 87 | 95 | ## Menus the tools file asks for |
| 88 | 96 | |
| @@ -82,7 +82,15 @@ The popup is refreshed from `tick`, not from the reading goroutine, which may no | |||
| 82 | 82 | ||
| 83 | The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work. | 83 | The exit is noticed on a goroutine that may not touch a buffer, so it sets `a.toolsRan` and the loop does the work. |
| 84 | 84 | ||
| 85 | -`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing. | 85 | +**Anything else rewriting a file is noticed the same way, by stamp.** `reloadChangedFiles` (`watch.go`) keeps what each open file looked like on disk — size and modification time, `fileStamp`, the same guard `refreshToolMenus` uses — by canonical path in `a.fileStamps`, and on every turn of the loop re-reads the ones whose stamp moved, through the `reloadFromDisk` the tools reload shares (so telling the language server, added for this, could not go missing from the other). The editor's own writes re-stamp in `openBuffer`, `afterSave` and the reload itself, so a save does not come round as a reload of the text just written. A modified buffer is kept and the status bar says so once. A file that has gone is left alone, silently: some programs replace a file by deleting it and writing it again, and a stat between the two sees nothing. |
| 86 | + | ||
| 87 | +The loop sits in `PollEvent` while the user is idle, which is exactly when another program is most likely to be writing — so a `fileWatch` goroutine, started by `Run` and only by `Run`, stats the same stamps once a second and posts a wake when any differs. It is a nudge and nothing more: the loop decides for itself, so a dropped wake costs a late reload, never a lost one, and an idle editor with unchanged files is never woken. Tests set `a.fileWatchInterval` short and watch the simulation screen for the interrupt event. | ||
| 88 | + | ||
| 89 | +`tick` is that loop turn, extracted so a test can take one. Everything in it is **state-driven** — `announceOpenDocuments`, `refreshRunningTool`, `reloadAfterTools`, `reloadChangedFiles`, `saveDueDocuments`, `refreshTerminalTitles` — for the same reason each time: the only way to wake this loop from another goroutine is `PostEvent`, which drops what does not fit, so **the wake-up may be lost and the state must not be.** Tests call `tick`, not the individual step, or removing a step from the loop would leave them passing. | ||
| 90 | + | ||
| 91 | +## A paste from outside | ||
| 92 | + | ||
| 93 | +`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, and the agent view reads the editor's clipboard through `OnPaste` for its own Ctrl-V. | ||
| 86 | 94 | ||
| 87 | ## Menus the tools file asks for | 95 | ## Menus the tools file asks for |
| 88 | 96 | ||
modified
app/actions_edit.go +10 -1 | @@ -19,9 +19,18 @@ func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } | ||
| 19 | 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } |
| 20 | 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } |
| 21 | 21 | func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } |
| 22 | -func (a *App) Paste() { a.withView(func(v *editor.View) { v.Paste() }) } | |
| 23 | 22 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } |
| 24 | 23 | |
| 24 | +// Paste puts the clipboard into the front window: the file being edited, or | |
| 25 | +// the box of an agent window, which is the one other place text is typed. | |
| 26 | +func (a *App) Paste() { | |
| 27 | + if agent := a.activeAgent(); agent != nil { | |
| 28 | + agent.view.Paste(a.clipboard.Text()) | |
| 29 | + return | |
| 30 | + } | |
| 31 | + a.withView(func(v *editor.View) { v.Paste() }) | |
| 32 | +} | |
| 33 | + | |
| 25 | 34 | // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line |
| 26 | 35 | // opened above the cursor, and the cursor's own line removed. |
| 27 | 36 | func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) } |
| @@ -19,9 +19,18 @@ func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } | |||
| 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } | 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } |
| 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } | 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } |
| 21 | func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } | 21 | func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } |
| 22 | -func (a *App) Paste() { a.withView(func(v *editor.View) { v.Paste() }) } | ||
| 23 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } | 22 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } |
| 24 | 23 | ||
| 24 | +// Paste puts the clipboard into the front window: the file being edited, or | ||
| 25 | +// the box of an agent window, which is the one other place text is typed. | ||
| 26 | +func (a *App) Paste() { | ||
| 27 | + if agent := a.activeAgent(); agent != nil { | ||
| 28 | + agent.view.Paste(a.clipboard.Text()) | ||
| 29 | + return | ||
| 30 | + } | ||
| 31 | + a.withView(func(v *editor.View) { v.Paste() }) | ||
| 32 | +} | ||
| 33 | + | ||
| 25 | // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line | 34 | // InsertLine and DeleteLine are Turbo C's Ctrl-N and Ctrl-Y — a blank line |
| 26 | // opened above the cursor, and the cursor's own line removed. | 35 | // opened above the cursor, and the cursor's own line removed. |
| 27 | func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) } | 36 | func (a *App) InsertLine() { a.withView(func(v *editor.View) { v.InsertLine() }) } |
modified
app/actions_file.go +2 -0 | @@ -86,6 +86,7 @@ func (a *App) openBuffer(buf *buffer.Buffer) { | ||
| 86 | 86 | |
| 87 | 87 | a.desktop.Add(window) |
| 88 | 88 | a.windowsOpened++ |
| 89 | + a.stampFile(buf.Path()) | |
| 89 | 90 | a.language.DidOpen(buf.Path(), buf.Text()) |
| 90 | 91 | } |
| 91 | 92 | |
| @@ -208,6 +209,7 @@ func renamed(previous, path string) bool { | ||
| 208 | 209 | // project's settings is exactly such a step, and would have been missing from |
| 209 | 210 | // autosave. |
| 210 | 211 | func (a *App) afterSave(view *editor.View, path string, created bool) { |
| 212 | + a.stampFile(path) | |
| 211 | 213 | view.RefreshSyntax() |
| 212 | 214 | if window := a.windowOf(view); window != nil { |
| 213 | 215 | window.SetTitle(windowTitle(view.Buffer())) |
| @@ -86,6 +86,7 @@ func (a *App) openBuffer(buf *buffer.Buffer) { | |||
| 86 | 86 | ||
| 87 | a.desktop.Add(window) | 87 | a.desktop.Add(window) |
| 88 | a.windowsOpened++ | 88 | a.windowsOpened++ |
| 89 | + a.stampFile(buf.Path()) | ||
| 89 | a.language.DidOpen(buf.Path(), buf.Text()) | 90 | a.language.DidOpen(buf.Path(), buf.Text()) |
| 90 | } | 91 | } |
| 91 | 92 | ||
| @@ -208,6 +209,7 @@ func renamed(previous, path string) bool { | |||
| 208 | // project's settings is exactly such a step, and would have been missing from | 209 | // project's settings is exactly such a step, and would have been missing from |
| 209 | // autosave. | 210 | // autosave. |
| 210 | func (a *App) afterSave(view *editor.View, path string, created bool) { | 211 | func (a *App) afterSave(view *editor.View, path string, created bool) { |
| 212 | + a.stampFile(path) | ||
| 211 | view.RefreshSyntax() | 213 | view.RefreshSyntax() |
| 212 | if window := a.windowOf(view); window != nil { | 214 | if window := a.windowOf(view); window != nil { |
| 213 | window.SetTitle(windowTitle(view.Buffer())) | 215 | window.SetTitle(windowTitle(view.Buffer())) |
modified
app/agents.go +1 -0 | @@ -57,6 +57,7 @@ func (a *App) NewAgent(name string) { | ||
| 57 | 57 | |
| 58 | 58 | view := acp.NewView(session) |
| 59 | 59 | view.OnCopy = a.copyFromAgent |
| 60 | + view.OnPaste = a.clipboard.Text | |
| 60 | 61 | view.Files = a.projectFiles |
| 61 | 62 | window := ui.NewWindow(view.Title(), view) |
| 62 | 63 | window.SetBounds(a.newWindowBounds()) |
| @@ -57,6 +57,7 @@ func (a *App) NewAgent(name string) { | |||
| 57 | 57 | ||
| 58 | view := acp.NewView(session) | 58 | view := acp.NewView(session) |
| 59 | view.OnCopy = a.copyFromAgent | 59 | view.OnCopy = a.copyFromAgent |
| 60 | + view.OnPaste = a.clipboard.Text | ||
| 60 | view.Files = a.projectFiles | 61 | view.Files = a.projectFiles |
| 61 | window := ui.NewWindow(view.Title(), view) | 62 | window := ui.NewWindow(view.Title(), view) |
| 62 | window.SetBounds(a.newWindowBounds()) | 63 | window.SetBounds(a.newWindowBounds()) |
modified
app/app.go +30 -0 | @@ -105,10 +105,23 @@ type App struct { | ||
| 105 | 105 | settingsPath string |
| 106 | 106 | // autosave is the pending automatic save, when the project asked for one. |
| 107 | 107 | autosave autosave |
| 108 | + // fileStamps is what each open file looked like on disk when the loop | |
| 109 | + // last saw it, by canonical path — how a change made by another program | |
| 110 | + // is noticed. fileWatch shares it with the goroutine that wakes the loop | |
| 111 | + // for one, at fileWatchInterval, while the editor is idle. | |
| 112 | + fileStamps map[string]fileStamp | |
| 113 | + fileWatch fileWatch | |
| 114 | + fileWatchInterval time.Duration | |
| 108 | 115 | // now is the clock, so that autosave's deadlines can be tested without |
| 109 | 116 | // waiting for them. |
| 110 | 117 | now func() time.Time |
| 111 | 118 | |
| 119 | + // pasting says the terminal is in the middle of a bracketed paste, and | |
| 120 | + // pasted is what it has sent so far: a paste is handed to an agent window | |
| 121 | + // whole, once it has ended, rather than key by key. | |
| 122 | + pasting bool | |
| 123 | + pasted []*tcell.EventKey | |
| 124 | + | |
| 112 | 125 | lastSearch string |
| 113 | 126 | lastMatchCase bool |
| 114 | 127 | windowsOpened int |
| @@ -137,10 +150,17 @@ func New(screen tcell.Screen, themeName string, p profile.Profile) *App { | ||
| 137 | 150 | terminals: map[*ui.Window]*terminal.View{}, |
| 138 | 151 | agents: map[*ui.Window]*agentWindow{}, |
| 139 | 152 | now: time.Now, |
| 153 | + | |
| 154 | + fileStamps: map[string]fileStamp{}, | |
| 155 | + fileWatchInterval: defaultFileWatchInterval, | |
| 140 | 156 | } |
| 141 | 157 | a.autosave.delay = settings.DefaultAutosaveDelay |
| 142 | 158 | |
| 143 | 159 | a.setTheme(themeName) |
| 160 | + // Bracketed paste, so that a paste from outside arrives between two | |
| 161 | + // EventPaste marks and can be told from typing. Without it forty lines | |
| 162 | + // pasted into an agent window are forty Enters, each one sending. | |
| 163 | + screen.EnablePaste() | |
| 144 | 164 | // Stamped before the bar is built, so a tools file written between the two |
| 145 | 165 | // is picked up by the next turn of the loop rather than missed. |
| 146 | 166 | a.toolsStamp = a.toolsFileStamp() |
| @@ -255,6 +275,9 @@ func (a *App) Language() *Language { return a.language } | ||
| 255 | 275 | |
| 256 | 276 | // Run draws and handles events until the user asks to leave. |
| 257 | 277 | func (a *App) Run() error { |
| 278 | + stop := a.watchFiles() | |
| 279 | + defer stop() | |
| 280 | + | |
| 258 | 281 | for !a.quitting { |
| 259 | 282 | a.tick() |
| 260 | 283 | a.layout() |
| @@ -286,6 +309,7 @@ func (a *App) tick() { | ||
| 286 | 309 | a.refreshToolMenus() |
| 287 | 310 | a.refreshRunningTool() |
| 288 | 311 | a.reloadAfterTools() |
| 312 | + a.reloadChangedFiles() | |
| 289 | 313 | a.saveDueDocuments() |
| 290 | 314 | a.refreshTerminalTitles() |
| 291 | 315 | a.refreshAgentTitles() |
| @@ -393,6 +417,8 @@ func (a *App) handle(event tcell.Event) { | ||
| 393 | 417 | switch typed := event.(type) { |
| 394 | 418 | case *tcell.EventResize: |
| 395 | 419 | a.resize() |
| 420 | + case *tcell.EventPaste: | |
| 421 | + a.handlePaste(typed) | |
| 396 | 422 | case *tcell.EventKey: |
| 397 | 423 | a.handleKey(typed) |
| 398 | 424 | case *tcell.EventMouse: |
| @@ -420,6 +446,10 @@ func (a *App) resize() { | ||
| 420 | 446 | // handleKey offers a key to each layer in turn, front to back, and gives what |
| 421 | 447 | // nobody claimed to the window on the desktop. |
| 422 | 448 | func (a *App) handleKey(ev *tcell.EventKey) { |
| 449 | + if a.pasting { | |
| 450 | + a.pasted = append(a.pasted, ev) | |
| 451 | + return | |
| 452 | + } | |
| 423 | 453 | a.status.SetMessage("") // any keystroke clears a transient message |
| 424 | 454 | |
| 425 | 455 | for _, claims := range a.keyLayers() { |
| @@ -105,10 +105,23 @@ type App struct { | |||
| 105 | settingsPath string | 105 | settingsPath string |
| 106 | // autosave is the pending automatic save, when the project asked for one. | 106 | // autosave is the pending automatic save, when the project asked for one. |
| 107 | autosave autosave | 107 | autosave autosave |
| 108 | + // fileStamps is what each open file looked like on disk when the loop | ||
| 109 | + // last saw it, by canonical path — how a change made by another program | ||
| 110 | + // is noticed. fileWatch shares it with the goroutine that wakes the loop | ||
| 111 | + // for one, at fileWatchInterval, while the editor is idle. | ||
| 112 | + fileStamps map[string]fileStamp | ||
| 113 | + fileWatch fileWatch | ||
| 114 | + fileWatchInterval time.Duration | ||
| 108 | // now is the clock, so that autosave's deadlines can be tested without | 115 | // now is the clock, so that autosave's deadlines can be tested without |
| 109 | // waiting for them. | 116 | // waiting for them. |
| 110 | now func() time.Time | 117 | now func() time.Time |
| 111 | 118 | ||
| 119 | + // pasting says the terminal is in the middle of a bracketed paste, and | ||
| 120 | + // pasted is what it has sent so far: a paste is handed to an agent window | ||
| 121 | + // whole, once it has ended, rather than key by key. | ||
| 122 | + pasting bool | ||
| 123 | + pasted []*tcell.EventKey | ||
| 124 | + | ||
| 112 | lastSearch string | 125 | lastSearch string |
| 113 | lastMatchCase bool | 126 | lastMatchCase bool |
| 114 | windowsOpened int | 127 | windowsOpened int |
| @@ -137,10 +150,17 @@ func New(screen tcell.Screen, themeName string, p profile.Profile) *App { | |||
| 137 | terminals: map[*ui.Window]*terminal.View{}, | 150 | terminals: map[*ui.Window]*terminal.View{}, |
| 138 | agents: map[*ui.Window]*agentWindow{}, | 151 | agents: map[*ui.Window]*agentWindow{}, |
| 139 | now: time.Now, | 152 | now: time.Now, |
| 153 | + | ||
| 154 | + fileStamps: map[string]fileStamp{}, | ||
| 155 | + fileWatchInterval: defaultFileWatchInterval, | ||
| 140 | } | 156 | } |
| 141 | a.autosave.delay = settings.DefaultAutosaveDelay | 157 | a.autosave.delay = settings.DefaultAutosaveDelay |
| 142 | 158 | ||
| 143 | a.setTheme(themeName) | 159 | a.setTheme(themeName) |
| 160 | + // Bracketed paste, so that a paste from outside arrives between two | ||
| 161 | + // EventPaste marks and can be told from typing. Without it forty lines | ||
| 162 | + // pasted into an agent window are forty Enters, each one sending. | ||
| 163 | + screen.EnablePaste() | ||
| 144 | // Stamped before the bar is built, so a tools file written between the two | 164 | // Stamped before the bar is built, so a tools file written between the two |
| 145 | // is picked up by the next turn of the loop rather than missed. | 165 | // is picked up by the next turn of the loop rather than missed. |
| 146 | a.toolsStamp = a.toolsFileStamp() | 166 | a.toolsStamp = a.toolsFileStamp() |
| @@ -255,6 +275,9 @@ func (a *App) Language() *Language { return a.language } | |||
| 255 | 275 | ||
| 256 | // Run draws and handles events until the user asks to leave. | 276 | // Run draws and handles events until the user asks to leave. |
| 257 | func (a *App) Run() error { | 277 | func (a *App) Run() error { |
| 278 | + stop := a.watchFiles() | ||
| 279 | + defer stop() | ||
| 280 | + | ||
| 258 | for !a.quitting { | 281 | for !a.quitting { |
| 259 | a.tick() | 282 | a.tick() |
| 260 | a.layout() | 283 | a.layout() |
| @@ -286,6 +309,7 @@ func (a *App) tick() { | |||
| 286 | a.refreshToolMenus() | 309 | a.refreshToolMenus() |
| 287 | a.refreshRunningTool() | 310 | a.refreshRunningTool() |
| 288 | a.reloadAfterTools() | 311 | a.reloadAfterTools() |
| 312 | + a.reloadChangedFiles() | ||
| 289 | a.saveDueDocuments() | 313 | a.saveDueDocuments() |
| 290 | a.refreshTerminalTitles() | 314 | a.refreshTerminalTitles() |
| 291 | a.refreshAgentTitles() | 315 | a.refreshAgentTitles() |
| @@ -393,6 +417,8 @@ func (a *App) handle(event tcell.Event) { | |||
| 393 | switch typed := event.(type) { | 417 | switch typed := event.(type) { |
| 394 | case *tcell.EventResize: | 418 | case *tcell.EventResize: |
| 395 | a.resize() | 419 | a.resize() |
| 420 | + case *tcell.EventPaste: | ||
| 421 | + a.handlePaste(typed) | ||
| 396 | case *tcell.EventKey: | 422 | case *tcell.EventKey: |
| 397 | a.handleKey(typed) | 423 | a.handleKey(typed) |
| 398 | case *tcell.EventMouse: | 424 | case *tcell.EventMouse: |
| @@ -420,6 +446,10 @@ func (a *App) resize() { | |||
| 420 | // handleKey offers a key to each layer in turn, front to back, and gives what | 446 | // handleKey offers a key to each layer in turn, front to back, and gives what |
| 421 | // nobody claimed to the window on the desktop. | 447 | // nobody claimed to the window on the desktop. |
| 422 | func (a *App) handleKey(ev *tcell.EventKey) { | 448 | func (a *App) handleKey(ev *tcell.EventKey) { |
| 449 | + if a.pasting { | ||
| 450 | + a.pasted = append(a.pasted, ev) | ||
| 451 | + return | ||
| 452 | + } | ||
| 423 | a.status.SetMessage("") // any keystroke clears a transient message | 453 | a.status.SetMessage("") // any keystroke clears a transient message |
| 424 | 454 | ||
| 425 | for _, claims := range a.keyLayers() { | 455 | for _, claims := range a.keyLayers() { |
added
app/paste.go +53 -0 | new file mode 100644 | ||
| @@ -0,0 +1,53 @@ | ||
| 1 | +// A paste from the terminal: collected between its two marks and handed to | |
| 2 | +// the window in front whole, or replayed as the keys it came as. | |
| 3 | + | |
| 4 | +package app | |
| 5 | + | |
| 6 | +import ( | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "github.com/gdamore/tcell/v2" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// handlePaste marks the start and end of a bracketed paste from the terminal. | |
| 13 | +// | |
| 14 | +// tcell sends the pasted text as ordinary keys between the two marks, so | |
| 15 | +// handleKey collects them while a paste is on, and the end mark decides | |
| 16 | +// where the whole goes. An agent window in front gets it as one paste, which | |
| 17 | +// is what lets a long one be kept aside as a token (acp.View.Paste). Any | |
| 18 | +// other window gets the keys replayed exactly as it would have had them, so | |
| 19 | +// nothing else changes: a paste into a file is typed, as it always was. | |
| 20 | +func (a *App) handlePaste(ev *tcell.EventPaste) { | |
| 21 | + if ev.Start() { | |
| 22 | + a.pasting, a.pasted = true, nil | |
| 23 | + return | |
| 24 | + } | |
| 25 | + keys := a.pasted | |
| 26 | + a.pasting, a.pasted = false, nil | |
| 27 | + | |
| 28 | + if agent := a.activeAgent(); agent != nil && len(a.modals) == 0 { | |
| 29 | + agent.view.Paste(pastedText(keys)) | |
| 30 | + return | |
| 31 | + } | |
| 32 | + for _, key := range keys { | |
| 33 | + a.handleKey(key) | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +// pastedText puts the keys of a bracketed paste back into the text they | |
| 38 | +// were: a rune for each character, a newline for each Enter, a tab for each | |
| 39 | +// Tab. Anything else a terminal might send inside a paste is not text. | |
| 40 | +func pastedText(keys []*tcell.EventKey) string { | |
| 41 | + var text strings.Builder | |
| 42 | + for _, key := range keys { | |
| 43 | + switch key.Key() { | |
| 44 | + case tcell.KeyRune: | |
| 45 | + text.WriteRune(key.Rune()) | |
| 46 | + case tcell.KeyEnter, tcell.KeyLF: | |
| 47 | + text.WriteByte('\n') | |
| 48 | + case tcell.KeyTab: | |
| 49 | + text.WriteByte('\t') | |
| 50 | + } | |
| 51 | + } | |
| 52 | + return text.String() | |
| 53 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | +// A paste from the terminal: collected between its two marks and handed to | ||
| 2 | +// the window in front whole, or replayed as the keys it came as. | ||
| 3 | + | ||
| 4 | +package app | ||
| 5 | + | ||
| 6 | +import ( | ||
| 7 | + "strings" | ||
| 8 | + | ||
| 9 | + "github.com/gdamore/tcell/v2" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// handlePaste marks the start and end of a bracketed paste from the terminal. | ||
| 13 | +// | ||
| 14 | +// tcell sends the pasted text as ordinary keys between the two marks, so | ||
| 15 | +// handleKey collects them while a paste is on, and the end mark decides | ||
| 16 | +// where the whole goes. An agent window in front gets it as one paste, which | ||
| 17 | +// is what lets a long one be kept aside as a token (acp.View.Paste). Any | ||
| 18 | +// other window gets the keys replayed exactly as it would have had them, so | ||
| 19 | +// nothing else changes: a paste into a file is typed, as it always was. | ||
| 20 | +func (a *App) handlePaste(ev *tcell.EventPaste) { | ||
| 21 | + if ev.Start() { | ||
| 22 | + a.pasting, a.pasted = true, nil | ||
| 23 | + return | ||
| 24 | + } | ||
| 25 | + keys := a.pasted | ||
| 26 | + a.pasting, a.pasted = false, nil | ||
| 27 | + | ||
| 28 | + if agent := a.activeAgent(); agent != nil && len(a.modals) == 0 { | ||
| 29 | + agent.view.Paste(pastedText(keys)) | ||
| 30 | + return | ||
| 31 | + } | ||
| 32 | + for _, key := range keys { | ||
| 33 | + a.handleKey(key) | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +// pastedText puts the keys of a bracketed paste back into the text they | ||
| 38 | +// were: a rune for each character, a newline for each Enter, a tab for each | ||
| 39 | +// Tab. Anything else a terminal might send inside a paste is not text. | ||
| 40 | +func pastedText(keys []*tcell.EventKey) string { | ||
| 41 | + var text strings.Builder | ||
| 42 | + for _, key := range keys { | ||
| 43 | + switch key.Key() { | ||
| 44 | + case tcell.KeyRune: | ||
| 45 | + text.WriteRune(key.Rune()) | ||
| 46 | + case tcell.KeyEnter, tcell.KeyLF: | ||
| 47 | + text.WriteByte('\n') | ||
| 48 | + case tcell.KeyTab: | ||
| 49 | + text.WriteByte('\t') | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + return text.String() | ||
| 53 | +} | ||
added
app/paste_test.go +92 -0 | new file mode 100644 | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "github.com/gdamore/tcell/v2" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// pasteKeys sends text as a terminal's bracketed paste would: a start mark, | |
| 11 | +// the characters as keys with Enter for each newline, an end mark. | |
| 12 | +func pasteKeys(a *App, text string) { | |
| 13 | + a.handle(tcell.NewEventPaste(true)) | |
| 14 | + for _, r := range text { | |
| 15 | + if r == '\n' { | |
| 16 | + a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) | |
| 17 | + continue | |
| 18 | + } | |
| 19 | + a.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) | |
| 20 | + } | |
| 21 | + a.handle(tcell.NewEventPaste(false)) | |
| 22 | +} | |
| 23 | + | |
| 24 | +func TestABracketedPasteReachesAnAgentWindowWhole(t *testing.T) { | |
| 25 | + // Key by key, the two Enters would each have sent a prompt; whole, the | |
| 26 | + // three lines are one paste, kept aside as a token. | |
| 27 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | |
| 28 | + a.NewAgent("Echo") | |
| 29 | + agent := a.activeAgent() | |
| 30 | + if agent == nil { | |
| 31 | + t.Fatal("no agent window is in front") | |
| 32 | + } | |
| 33 | + | |
| 34 | + pasteKeys(a, "one\ntwo\nthree") | |
| 35 | + | |
| 36 | + if got := agent.view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { | |
| 37 | + t.Errorf("the box holds %q", got) | |
| 38 | + } | |
| 39 | + if entries := agent.session.Entries(); len(entries) != 0 { | |
| 40 | + t.Errorf("%d prompt(s) were sent by the newlines inside the paste", len(entries)) | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +func TestABracketedPasteIntoAFileIsTypedAsBefore(t *testing.T) { | |
| 45 | + a, _ := newTestApp(t) | |
| 46 | + a.NewFile() | |
| 47 | + | |
| 48 | + pasteKeys(a, "ab") | |
| 49 | + | |
| 50 | + if got := a.activeView().Buffer().Text(); got != "ab" { | |
| 51 | + t.Errorf("the file holds %q after a paste, want %q", got, "ab") | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +func TestKeysAreNotLostWhenAPasteIsCancelledByAnotherWindow(t *testing.T) { | |
| 56 | + // The paste starts in an agent window; nothing else in the editor sees | |
| 57 | + // its keys until it ends — so a key layer cannot half-act on them. | |
| 58 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | |
| 59 | + a.NewAgent("Echo") | |
| 60 | + | |
| 61 | + a.handle(tcell.NewEventPaste(true)) | |
| 62 | + a.handle(tcell.NewEventKey(tcell.KeyF4, 0, tcell.ModNone)) // New file, were it a keystroke | |
| 63 | + a.handle(tcell.NewEventPaste(false)) | |
| 64 | + | |
| 65 | + if len(a.desktop.Windows()) != 1 { | |
| 66 | + t.Errorf("%d windows are open: a key inside a paste acted as a shortcut", len(a.desktop.Windows())) | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +func TestEditPasteGoesIntoTheAgentBox(t *testing.T) { | |
| 71 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | |
| 72 | + a.NewAgent("Echo") | |
| 73 | + a.clipboard.SetText("from a file\nin the editor\n") | |
| 74 | + | |
| 75 | + a.Paste() | |
| 76 | + | |
| 77 | + if got := a.activeAgent().view.Input(); !strings.HasPrefix(got, "[Pasted #1 · 2 lines") { | |
| 78 | + t.Errorf("the box holds %q after Edit ▸ Paste", got) | |
| 79 | + } | |
| 80 | +} | |
| 81 | + | |
| 82 | +func TestCtrlVInAnAgentWindowPastesTheEditorsClipboard(t *testing.T) { | |
| 83 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | |
| 84 | + a.NewAgent("Echo") | |
| 85 | + a.clipboard.SetText("buildMenus") | |
| 86 | + | |
| 87 | + press(a, tcell.KeyCtrlV, 0, tcell.ModNone) | |
| 88 | + | |
| 89 | + if got := a.activeAgent().view.Input(); got != "buildMenus" { | |
| 90 | + t.Errorf("the box holds %q after Ctrl-V", got) | |
| 91 | + } | |
| 92 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "github.com/gdamore/tcell/v2" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// pasteKeys sends text as a terminal's bracketed paste would: a start mark, | ||
| 11 | +// the characters as keys with Enter for each newline, an end mark. | ||
| 12 | +func pasteKeys(a *App, text string) { | ||
| 13 | + a.handle(tcell.NewEventPaste(true)) | ||
| 14 | + for _, r := range text { | ||
| 15 | + if r == '\n' { | ||
| 16 | + a.handle(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) | ||
| 17 | + continue | ||
| 18 | + } | ||
| 19 | + a.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) | ||
| 20 | + } | ||
| 21 | + a.handle(tcell.NewEventPaste(false)) | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +func TestABracketedPasteReachesAnAgentWindowWhole(t *testing.T) { | ||
| 25 | + // Key by key, the two Enters would each have sent a prompt; whole, the | ||
| 26 | + // three lines are one paste, kept aside as a token. | ||
| 27 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | ||
| 28 | + a.NewAgent("Echo") | ||
| 29 | + agent := a.activeAgent() | ||
| 30 | + if agent == nil { | ||
| 31 | + t.Fatal("no agent window is in front") | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + pasteKeys(a, "one\ntwo\nthree") | ||
| 35 | + | ||
| 36 | + if got := agent.view.Input(); got != "[Pasted #1 · 3 lines · 13 chars]" { | ||
| 37 | + t.Errorf("the box holds %q", got) | ||
| 38 | + } | ||
| 39 | + if entries := agent.session.Entries(); len(entries) != 0 { | ||
| 40 | + t.Errorf("%d prompt(s) were sent by the newlines inside the paste", len(entries)) | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +func TestABracketedPasteIntoAFileIsTypedAsBefore(t *testing.T) { | ||
| 45 | + a, _ := newTestApp(t) | ||
| 46 | + a.NewFile() | ||
| 47 | + | ||
| 48 | + pasteKeys(a, "ab") | ||
| 49 | + | ||
| 50 | + if got := a.activeView().Buffer().Text(); got != "ab" { | ||
| 51 | + t.Errorf("the file holds %q after a paste, want %q", got, "ab") | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +func TestKeysAreNotLostWhenAPasteIsCancelledByAnotherWindow(t *testing.T) { | ||
| 56 | + // The paste starts in an agent window; nothing else in the editor sees | ||
| 57 | + // its keys until it ends — so a key layer cannot half-act on them. | ||
| 58 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | ||
| 59 | + a.NewAgent("Echo") | ||
| 60 | + | ||
| 61 | + a.handle(tcell.NewEventPaste(true)) | ||
| 62 | + a.handle(tcell.NewEventKey(tcell.KeyF4, 0, tcell.ModNone)) // New file, were it a keystroke | ||
| 63 | + a.handle(tcell.NewEventPaste(false)) | ||
| 64 | + | ||
| 65 | + if len(a.desktop.Windows()) != 1 { | ||
| 66 | + t.Errorf("%d windows are open: a key inside a paste acted as a shortcut", len(a.desktop.Windows())) | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +func TestEditPasteGoesIntoTheAgentBox(t *testing.T) { | ||
| 71 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | ||
| 72 | + a.NewAgent("Echo") | ||
| 73 | + a.clipboard.SetText("from a file\nin the editor\n") | ||
| 74 | + | ||
| 75 | + a.Paste() | ||
| 76 | + | ||
| 77 | + if got := a.activeAgent().view.Input(); !strings.HasPrefix(got, "[Pasted #1 · 2 lines") { | ||
| 78 | + t.Errorf("the box holds %q after Edit ▸ Paste", got) | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +func TestCtrlVInAnAgentWindowPastesTheEditorsClipboard(t *testing.T) { | ||
| 83 | + a, _ := newAgentsApp(t, "[[agent]]\nname=\"Echo\"\ncommand=\"cat\"\n") | ||
| 84 | + a.NewAgent("Echo") | ||
| 85 | + a.clipboard.SetText("buildMenus") | ||
| 86 | + | ||
| 87 | + press(a, tcell.KeyCtrlV, 0, tcell.ModNone) | ||
| 88 | + | ||
| 89 | + if got := a.activeAgent().view.Input(); got != "buildMenus" { | ||
| 90 | + t.Errorf("the box holds %q after Ctrl-V", got) | ||
| 91 | + } | ||
| 92 | +} | ||
modified
app/toolchain.go +2 -6 | @@ -360,13 +360,9 @@ func (a *App) reloadUnmodifiedBuffers() (reloaded, skipped int) { | ||
| 360 | 360 | continue |
| 361 | 361 | } |
| 362 | 362 | |
| 363 | - changed, err := view.Buffer().Reload() | |
| 364 | - if err != nil || !changed { | |
| 365 | - continue // a file that has gone, or one nothing touched | |
| 363 | + if a.reloadFromDisk(window, view) { | |
| 364 | + reloaded++ | |
| 366 | 365 | } |
| 367 | - view.RefreshSyntax() | |
| 368 | - window.SetTitle(windowTitle(view.Buffer())) | |
| 369 | - reloaded++ | |
| 370 | 366 | } |
| 371 | 367 | return reloaded, skipped |
| 372 | 368 | } |
| @@ -360,13 +360,9 @@ func (a *App) reloadUnmodifiedBuffers() (reloaded, skipped int) { | |||
| 360 | continue | 360 | continue |
| 361 | } | 361 | } |
| 362 | 362 | ||
| 363 | - changed, err := view.Buffer().Reload() | 363 | + if a.reloadFromDisk(window, view) { |
| 364 | - if err != nil || !changed { | 364 | + reloaded++ |
| 365 | - continue // a file that has gone, or one nothing touched | ||
| 366 | } | 365 | } |
| 367 | - view.RefreshSyntax() | ||
| 368 | - window.SetTitle(windowTitle(view.Buffer())) | ||
| 369 | - reloaded++ | ||
| 370 | } | 366 | } |
| 371 | return reloaded, skipped | 367 | return reloaded, skipped |
| 372 | } | 368 | } |
added
app/watch.go +184 -0 | new file mode 100644 | ||
| @@ -0,0 +1,184 @@ | ||
| 1 | +// Files changing on disk while they are open: noticing it, and re-reading them | |
| 2 | +// when nothing typed in the window would be lost. | |
| 3 | + | |
| 4 | +package app | |
| 5 | + | |
| 6 | +import ( | |
| 7 | + "path/filepath" | |
| 8 | + "sync" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "rickub.com/turbo-editors/turbo-core/editor" | |
| 12 | + "rickub.com/turbo-editors/turbo-core/ui" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// defaultFileWatchInterval is how often the disk is looked at for a change to | |
| 16 | +// a file that is open, while the editor is otherwise idle. | |
| 17 | +// | |
| 18 | +// One second is what a person notices as "straight away" and what a machine | |
| 19 | +// does not notice at all: one stat per open window per second. | |
| 20 | +const defaultFileWatchInterval = time.Second | |
| 21 | + | |
| 22 | +// fileWatch is what the event loop knows about the files on disk behind its | |
| 23 | +// windows, shared with the goroutine that watches them while the loop sleeps. | |
| 24 | +// | |
| 25 | +// The loop is the only thing that may touch a buffer, and it sits blocked in | |
| 26 | +// PollEvent until something happens — so a file rewritten by a formatter, a | |
| 27 | +// generator or a coding agent working in another terminal changed nothing on | |
| 28 | +// screen until the next keystroke, and then only because the tools reload | |
| 29 | +// happened to run. The goroutine here exists solely to wake the loop; the loop | |
| 30 | +// then decides for itself, by stat, what actually changed. That split is the | |
| 31 | +// same one every other state-driven step in tick makes: a wake-up may be | |
| 32 | +// dropped, so it must never be the only thing carrying a fact. | |
| 33 | +type fileWatch struct { | |
| 34 | + mu sync.Mutex | |
| 35 | + // stamps is what the loop last saw of each open file, by canonical path. | |
| 36 | + // Written by the loop, read by the watching goroutine. | |
| 37 | + stamps map[string]fileStamp | |
| 38 | +} | |
| 39 | + | |
| 40 | +// publish hands the goroutine what the loop has just seen on disk. | |
| 41 | +func (w *fileWatch) publish(stamps map[string]fileStamp) { | |
| 42 | + w.mu.Lock() | |
| 43 | + defer w.mu.Unlock() | |
| 44 | + w.stamps = stamps | |
| 45 | +} | |
| 46 | + | |
| 47 | +// changed reports whether any file the loop last saw is now different on | |
| 48 | +// disk. It reads the disk and nothing else, so it is safe from any goroutine. | |
| 49 | +func (w *fileWatch) changed() bool { | |
| 50 | + w.mu.Lock() | |
| 51 | + defer w.mu.Unlock() | |
| 52 | + for path, stamp := range w.stamps { | |
| 53 | + if stampOf(path) != stamp { | |
| 54 | + return true | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return false | |
| 58 | +} | |
| 59 | + | |
| 60 | +// watchFiles starts looking at the disk behind the open windows, and returns | |
| 61 | +// what stops it. Only Run calls it: a test drives the loop by hand and has no | |
| 62 | +// need of a wake-up. | |
| 63 | +// | |
| 64 | +// A dropped wake costs nothing here — the file is still different next time | |
| 65 | +// round, so the goroutine wakes the loop again a second later. | |
| 66 | +func (a *App) watchFiles() (stop func()) { | |
| 67 | + done := make(chan struct{}) | |
| 68 | + go func() { | |
| 69 | + ticker := time.NewTicker(a.fileWatchInterval) | |
| 70 | + defer ticker.Stop() | |
| 71 | + for { | |
| 72 | + select { | |
| 73 | + case <-done: | |
| 74 | + return | |
| 75 | + case <-ticker.C: | |
| 76 | + if a.fileWatch.changed() { | |
| 77 | + a.wake() | |
| 78 | + } | |
| 79 | + } | |
| 80 | + } | |
| 81 | + }() | |
| 82 | + return func() { close(done) } | |
| 83 | +} | |
| 84 | + | |
| 85 | +// stampFile records what a file looks like on disk now, so that a later turn | |
| 86 | +// of the loop compares against this and not against something older. | |
| 87 | +// | |
| 88 | +// It is called wherever the editor itself is the reason the file changed — | |
| 89 | +// opening it, saving it, re-reading it — because a change the editor made is | |
| 90 | +// not one it needs to be told about. | |
| 91 | +func (a *App) stampFile(path string) { | |
| 92 | + if path == "" { | |
| 93 | + return | |
| 94 | + } | |
| 95 | + a.fileStamps[pathKey(path)] = stampOf(path) | |
| 96 | +} | |
| 97 | + | |
| 98 | +// reloadChangedFiles re-reads every open file that something else rewrote. | |
| 99 | +// | |
| 100 | +// It runs at the top of every turn of the event loop, like the other | |
| 101 | +// state-driven work, and costs one stat per open window. A file whose stamp — | |
| 102 | +// size and modification time — is what it was last turn is not read. | |
| 103 | +// | |
| 104 | +// **A buffer with unsaved changes is never reloaded**, whatever happened on | |
| 105 | +// disk. The window says so on the status bar, once per change, and keeps the | |
| 106 | +// user's work; the next save writes the buffer over the file, which is the | |
| 107 | +// only answer the editor is entitled to give. A file that has gone from disk | |
| 108 | +// is left as it is too, silently: it is still what the window shows, and | |
| 109 | +// saving it puts it back. | |
| 110 | +func (a *App) reloadChangedFiles() { | |
| 111 | + open := map[string]bool{} | |
| 112 | + for _, window := range a.desktop.Windows() { | |
| 113 | + view, ok := editorViewOf(window) | |
| 114 | + if !ok || view.Buffer().Path() == "" { | |
| 115 | + continue | |
| 116 | + } | |
| 117 | + path := view.Buffer().Path() | |
| 118 | + key := pathKey(path) | |
| 119 | + open[key] = true | |
| 120 | + | |
| 121 | + stamp := stampOf(path) | |
| 122 | + last, known := a.fileStamps[key] | |
| 123 | + a.fileStamps[key] = stamp | |
| 124 | + if !known || stamp == last { | |
| 125 | + continue | |
| 126 | + } | |
| 127 | + a.fileChangedOnDisk(window, view) | |
| 128 | + } | |
| 129 | + | |
| 130 | + // Stamps outlive their windows otherwise — and a closed window's file | |
| 131 | + // would still wake the loop every time something touched it. | |
| 132 | + for key := range a.fileStamps { | |
| 133 | + if !open[key] { | |
| 134 | + delete(a.fileStamps, key) | |
| 135 | + } | |
| 136 | + } | |
| 137 | + a.fileWatch.publish(copyStamps(a.fileStamps)) | |
| 138 | +} | |
| 139 | + | |
| 140 | +// fileChangedOnDisk is what one window does about its file having changed | |
| 141 | +// under it: re-read it when nothing would be lost, say so when something | |
| 142 | +// would. | |
| 143 | +func (a *App) fileChangedOnDisk(window *ui.Window, view *editor.View) { | |
| 144 | + name := filepath.Base(view.Buffer().Path()) | |
| 145 | + if view.Buffer().Modified() { | |
| 146 | + a.Message(name + " changed on disk; your unsaved changes are kept") | |
| 147 | + return | |
| 148 | + } | |
| 149 | + if a.reloadFromDisk(window, view) { | |
| 150 | + a.Message("Reloaded " + name) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +// reloadFromDisk re-reads one unmodified window's file and brings everything | |
| 155 | +// that depends on its text up to date, reporting whether the text changed. | |
| 156 | +// | |
| 157 | +// It is one function for the two callers — a command from the tools menu | |
| 158 | +// finishing, and a change noticed on disk — so that a step added for one | |
| 159 | +// cannot go missing from the other. Telling the language server is exactly | |
| 160 | +// such a step: a server answering completions from the text the window | |
| 161 | +// showed before a formatter ran is the same stale-copy problem in a different | |
| 162 | +// place. | |
| 163 | +func (a *App) reloadFromDisk(window *ui.Window, view *editor.View) bool { | |
| 164 | + buf := view.Buffer() | |
| 165 | + changed, err := buf.Reload() | |
| 166 | + a.stampFile(buf.Path()) | |
| 167 | + if err != nil || !changed { | |
| 168 | + return false // a file that has gone, or one nothing touched | |
| 169 | + } | |
| 170 | + view.RefreshSyntax() | |
| 171 | + window.SetTitle(windowTitle(buf)) | |
| 172 | + a.language.DidChange(buf.Path(), buf.Text()) | |
| 173 | + return true | |
| 174 | +} | |
| 175 | + | |
| 176 | +// copyStamps returns a map the goroutine can read while the loop goes on | |
| 177 | +// writing its own. | |
| 178 | +func copyStamps(stamps map[string]fileStamp) map[string]fileStamp { | |
| 179 | + out := make(map[string]fileStamp, len(stamps)) | |
| 180 | + for key, stamp := range stamps { | |
| 181 | + out[key] = stamp | |
| 182 | + } | |
| 183 | + return out | |
| 184 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,184 @@ | |||
| 1 | +// Files changing on disk while they are open: noticing it, and re-reading them | ||
| 2 | +// when nothing typed in the window would be lost. | ||
| 3 | + | ||
| 4 | +package app | ||
| 5 | + | ||
| 6 | +import ( | ||
| 7 | + "path/filepath" | ||
| 8 | + "sync" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "rickub.com/turbo-editors/turbo-core/editor" | ||
| 12 | + "rickub.com/turbo-editors/turbo-core/ui" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// defaultFileWatchInterval is how often the disk is looked at for a change to | ||
| 16 | +// a file that is open, while the editor is otherwise idle. | ||
| 17 | +// | ||
| 18 | +// One second is what a person notices as "straight away" and what a machine | ||
| 19 | +// does not notice at all: one stat per open window per second. | ||
| 20 | +const defaultFileWatchInterval = time.Second | ||
| 21 | + | ||
| 22 | +// fileWatch is what the event loop knows about the files on disk behind its | ||
| 23 | +// windows, shared with the goroutine that watches them while the loop sleeps. | ||
| 24 | +// | ||
| 25 | +// The loop is the only thing that may touch a buffer, and it sits blocked in | ||
| 26 | +// PollEvent until something happens — so a file rewritten by a formatter, a | ||
| 27 | +// generator or a coding agent working in another terminal changed nothing on | ||
| 28 | +// screen until the next keystroke, and then only because the tools reload | ||
| 29 | +// happened to run. The goroutine here exists solely to wake the loop; the loop | ||
| 30 | +// then decides for itself, by stat, what actually changed. That split is the | ||
| 31 | +// same one every other state-driven step in tick makes: a wake-up may be | ||
| 32 | +// dropped, so it must never be the only thing carrying a fact. | ||
| 33 | +type fileWatch struct { | ||
| 34 | + mu sync.Mutex | ||
| 35 | + // stamps is what the loop last saw of each open file, by canonical path. | ||
| 36 | + // Written by the loop, read by the watching goroutine. | ||
| 37 | + stamps map[string]fileStamp | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +// publish hands the goroutine what the loop has just seen on disk. | ||
| 41 | +func (w *fileWatch) publish(stamps map[string]fileStamp) { | ||
| 42 | + w.mu.Lock() | ||
| 43 | + defer w.mu.Unlock() | ||
| 44 | + w.stamps = stamps | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// changed reports whether any file the loop last saw is now different on | ||
| 48 | +// disk. It reads the disk and nothing else, so it is safe from any goroutine. | ||
| 49 | +func (w *fileWatch) changed() bool { | ||
| 50 | + w.mu.Lock() | ||
| 51 | + defer w.mu.Unlock() | ||
| 52 | + for path, stamp := range w.stamps { | ||
| 53 | + if stampOf(path) != stamp { | ||
| 54 | + return true | ||
| 55 | + } | ||
| 56 | + } | ||
| 57 | + return false | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// watchFiles starts looking at the disk behind the open windows, and returns | ||
| 61 | +// what stops it. Only Run calls it: a test drives the loop by hand and has no | ||
| 62 | +// need of a wake-up. | ||
| 63 | +// | ||
| 64 | +// A dropped wake costs nothing here — the file is still different next time | ||
| 65 | +// round, so the goroutine wakes the loop again a second later. | ||
| 66 | +func (a *App) watchFiles() (stop func()) { | ||
| 67 | + done := make(chan struct{}) | ||
| 68 | + go func() { | ||
| 69 | + ticker := time.NewTicker(a.fileWatchInterval) | ||
| 70 | + defer ticker.Stop() | ||
| 71 | + for { | ||
| 72 | + select { | ||
| 73 | + case <-done: | ||
| 74 | + return | ||
| 75 | + case <-ticker.C: | ||
| 76 | + if a.fileWatch.changed() { | ||
| 77 | + a.wake() | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + }() | ||
| 82 | + return func() { close(done) } | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +// stampFile records what a file looks like on disk now, so that a later turn | ||
| 86 | +// of the loop compares against this and not against something older. | ||
| 87 | +// | ||
| 88 | +// It is called wherever the editor itself is the reason the file changed — | ||
| 89 | +// opening it, saving it, re-reading it — because a change the editor made is | ||
| 90 | +// not one it needs to be told about. | ||
| 91 | +func (a *App) stampFile(path string) { | ||
| 92 | + if path == "" { | ||
| 93 | + return | ||
| 94 | + } | ||
| 95 | + a.fileStamps[pathKey(path)] = stampOf(path) | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +// reloadChangedFiles re-reads every open file that something else rewrote. | ||
| 99 | +// | ||
| 100 | +// It runs at the top of every turn of the event loop, like the other | ||
| 101 | +// state-driven work, and costs one stat per open window. A file whose stamp — | ||
| 102 | +// size and modification time — is what it was last turn is not read. | ||
| 103 | +// | ||
| 104 | +// **A buffer with unsaved changes is never reloaded**, whatever happened on | ||
| 105 | +// disk. The window says so on the status bar, once per change, and keeps the | ||
| 106 | +// user's work; the next save writes the buffer over the file, which is the | ||
| 107 | +// only answer the editor is entitled to give. A file that has gone from disk | ||
| 108 | +// is left as it is too, silently: it is still what the window shows, and | ||
| 109 | +// saving it puts it back. | ||
| 110 | +func (a *App) reloadChangedFiles() { | ||
| 111 | + open := map[string]bool{} | ||
| 112 | + for _, window := range a.desktop.Windows() { | ||
| 113 | + view, ok := editorViewOf(window) | ||
| 114 | + if !ok || view.Buffer().Path() == "" { | ||
| 115 | + continue | ||
| 116 | + } | ||
| 117 | + path := view.Buffer().Path() | ||
| 118 | + key := pathKey(path) | ||
| 119 | + open[key] = true | ||
| 120 | + | ||
| 121 | + stamp := stampOf(path) | ||
| 122 | + last, known := a.fileStamps[key] | ||
| 123 | + a.fileStamps[key] = stamp | ||
| 124 | + if !known || stamp == last { | ||
| 125 | + continue | ||
| 126 | + } | ||
| 127 | + a.fileChangedOnDisk(window, view) | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + // Stamps outlive their windows otherwise — and a closed window's file | ||
| 131 | + // would still wake the loop every time something touched it. | ||
| 132 | + for key := range a.fileStamps { | ||
| 133 | + if !open[key] { | ||
| 134 | + delete(a.fileStamps, key) | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + a.fileWatch.publish(copyStamps(a.fileStamps)) | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// fileChangedOnDisk is what one window does about its file having changed | ||
| 141 | +// under it: re-read it when nothing would be lost, say so when something | ||
| 142 | +// would. | ||
| 143 | +func (a *App) fileChangedOnDisk(window *ui.Window, view *editor.View) { | ||
| 144 | + name := filepath.Base(view.Buffer().Path()) | ||
| 145 | + if view.Buffer().Modified() { | ||
| 146 | + a.Message(name + " changed on disk; your unsaved changes are kept") | ||
| 147 | + return | ||
| 148 | + } | ||
| 149 | + if a.reloadFromDisk(window, view) { | ||
| 150 | + a.Message("Reloaded " + name) | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// reloadFromDisk re-reads one unmodified window's file and brings everything | ||
| 155 | +// that depends on its text up to date, reporting whether the text changed. | ||
| 156 | +// | ||
| 157 | +// It is one function for the two callers — a command from the tools menu | ||
| 158 | +// finishing, and a change noticed on disk — so that a step added for one | ||
| 159 | +// cannot go missing from the other. Telling the language server is exactly | ||
| 160 | +// such a step: a server answering completions from the text the window | ||
| 161 | +// showed before a formatter ran is the same stale-copy problem in a different | ||
| 162 | +// place. | ||
| 163 | +func (a *App) reloadFromDisk(window *ui.Window, view *editor.View) bool { | ||
| 164 | + buf := view.Buffer() | ||
| 165 | + changed, err := buf.Reload() | ||
| 166 | + a.stampFile(buf.Path()) | ||
| 167 | + if err != nil || !changed { | ||
| 168 | + return false // a file that has gone, or one nothing touched | ||
| 169 | + } | ||
| 170 | + view.RefreshSyntax() | ||
| 171 | + window.SetTitle(windowTitle(buf)) | ||
| 172 | + a.language.DidChange(buf.Path(), buf.Text()) | ||
| 173 | + return true | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +// copyStamps returns a map the goroutine can read while the loop goes on | ||
| 177 | +// writing its own. | ||
| 178 | +func copyStamps(stamps map[string]fileStamp) map[string]fileStamp { | ||
| 179 | + out := make(map[string]fileStamp, len(stamps)) | ||
| 180 | + for key, stamp := range stamps { | ||
| 181 | + out[key] = stamp | ||
| 182 | + } | ||
| 183 | + return out | ||
| 184 | +} | ||
added
app/watch_test.go +201 -0 | new file mode 100644 | ||
| @@ -0,0 +1,201 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + "time" | |
| 9 | + | |
| 10 | + "github.com/gdamore/tcell/v2" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// newWatchedFile returns an editor with a file open in the front window, and | |
| 14 | +// that file's path. The contents written later are deliberately of a different | |
| 15 | +// length: on a filesystem that stamps modification times by the second, two | |
| 16 | +// writes in the same second are told apart by size or not at all. | |
| 17 | +func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) { | |
| 18 | + t.Helper() | |
| 19 | + | |
| 20 | + a, screen := newTestApp(t) | |
| 21 | + path := filepath.Join(t.TempDir(), "main.go") | |
| 22 | + writeTestFile(t, path, "package main\n") | |
| 23 | + a.Open(path) | |
| 24 | + a.tick() // the loop has seen the file as it was opened | |
| 25 | + return a, screen, path | |
| 26 | +} | |
| 27 | + | |
| 28 | +func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) { | |
| 29 | + // This is the whole feature: a formatter, a generator or an agent in | |
| 30 | + // another terminal rewrites the file, and the window shows it without | |
| 31 | + // closing and reopening. | |
| 32 | + a, _, path := newWatchedFile(t) | |
| 33 | + | |
| 34 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | |
| 35 | + a.tick() | |
| 36 | + | |
| 37 | + if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") { | |
| 38 | + t.Errorf("the window still shows %q after the file changed on disk", got) | |
| 39 | + } | |
| 40 | + if got := a.status.Message(); got != "Reloaded main.go" { | |
| 41 | + t.Errorf("status message = %q, want %q", got, "Reloaded main.go") | |
| 42 | + } | |
| 43 | + if title := a.desktop.Active().Title(); title != "main.go" { | |
| 44 | + t.Errorf("window title = %q after a reload, want %q", title, "main.go") | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) { | |
| 49 | + // The user's work and the other program's genuinely conflict, and the | |
| 50 | + // editor is not the one to decide — so it keeps the work and says so. | |
| 51 | + a, _, path := newWatchedFile(t) | |
| 52 | + typeText(a, "// mine") | |
| 53 | + edited := activeEditorText(t, a, path) | |
| 54 | + | |
| 55 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | |
| 56 | + a.tick() | |
| 57 | + | |
| 58 | + if got := activeEditorText(t, a, path); got != edited { | |
| 59 | + t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited) | |
| 60 | + } | |
| 61 | + if got := a.status.Message(); !strings.Contains(got, "changed on disk") { | |
| 62 | + t.Errorf("status message = %q, want it to say the file changed on disk", got) | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) { | |
| 67 | + // The message would otherwise be re-issued on every turn of the loop for as | |
| 68 | + // long as the window stays modified, hiding everything else the status bar | |
| 69 | + // has to say. | |
| 70 | + a, _, path := newWatchedFile(t) | |
| 71 | + typeText(a, "// mine") | |
| 72 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | |
| 73 | + a.tick() | |
| 74 | + | |
| 75 | + a.Message("something else") | |
| 76 | + a.tick() | |
| 77 | + | |
| 78 | + if got := a.status.Message(); got != "something else" { | |
| 79 | + t.Errorf("status message = %q; the change on disk was reported a second time", got) | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) { | |
| 84 | + // A save rewrites the file — through a rename, so with a new inode and | |
| 85 | + // stamp — and must not come round as a reload of the text just written. | |
| 86 | + a, _, path := newWatchedFile(t) | |
| 87 | + typeText(a, "// mine") | |
| 88 | + a.SaveFile() | |
| 89 | + a.tick() | |
| 90 | + | |
| 91 | + if got := a.status.Message(); got != "Saved main.go" { | |
| 92 | + t.Errorf("status message = %q after a save, want %q", got, "Saved main.go") | |
| 93 | + } | |
| 94 | + if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") { | |
| 95 | + t.Errorf("the saved text was lost: %q", got) | |
| 96 | + } | |
| 97 | +} | |
| 98 | + | |
| 99 | +func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) { | |
| 100 | + // Some programs replace a file by deleting it and writing a new one; a | |
| 101 | + // stat between the two sees nothing. The window keeps what it has, and | |
| 102 | + // picks the file up when it is there again. | |
| 103 | + a, _, path := newWatchedFile(t) | |
| 104 | + | |
| 105 | + if err := os.Remove(path); err != nil { | |
| 106 | + t.Fatal(err) | |
| 107 | + } | |
| 108 | + a.tick() | |
| 109 | + if got := activeEditorText(t, a, path); got != "package main\n" { | |
| 110 | + t.Errorf("the window lost its text when the file went: %q", got) | |
| 111 | + } | |
| 112 | + | |
| 113 | + writeTestFile(t, path, "package main\n\n// back again, longer\n") | |
| 114 | + a.tick() | |
| 115 | + if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") { | |
| 116 | + t.Errorf("the window did not pick the file up when it came back: %q", got) | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) { | |
| 121 | + // A server answering completions from the text before the formatter ran | |
| 122 | + // is the same stale-copy problem in another place. | |
| 123 | + a, server, project := newCodeApp(t) | |
| 124 | + path := filepath.Join(project, "main.go") | |
| 125 | + waitForMethod(t, server, "textDocument/didOpen") | |
| 126 | + a.tick() | |
| 127 | + before := server.methodCount("textDocument/didChange") | |
| 128 | + | |
| 129 | + writeTestFile(t, path, "package main\n\nfunc main() { println() }\n") | |
| 130 | + a.tick() | |
| 131 | + | |
| 132 | + deadline := time.After(2 * time.Second) | |
| 133 | + for server.methodCount("textDocument/didChange") == before { | |
| 134 | + select { | |
| 135 | + case <-deadline: | |
| 136 | + t.Fatal("the server was never told the reloaded text") | |
| 137 | + case <-time.After(time.Millisecond): | |
| 138 | + } | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) { | |
| 143 | + a, _, path := newWatchedFile(t) | |
| 144 | + | |
| 145 | + a.CloseFile() | |
| 146 | + a.tick() | |
| 147 | + writeTestFile(t, path, "package main\n\n// nobody is looking\n") | |
| 148 | + | |
| 149 | + if len(a.fileStamps) != 0 { | |
| 150 | + t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps)) | |
| 151 | + } | |
| 152 | + if a.fileWatch.changed() { | |
| 153 | + t.Error("the watcher would wake the loop for a file no window shows") | |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) { | |
| 158 | + // Run sits in PollEvent; without this wake the reload would wait for the | |
| 159 | + // next keystroke, which is the defect being fixed. | |
| 160 | + a, screen, path := newWatchedFile(t) | |
| 161 | + a.fileWatchInterval = 5 * time.Millisecond | |
| 162 | + stop := a.watchFiles() | |
| 163 | + defer stop() | |
| 164 | + | |
| 165 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | |
| 166 | + | |
| 167 | + if !wokeWithin(screen, time.Second) { | |
| 168 | + t.Fatal("the event loop was never woken for a file that changed on disk") | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 172 | +func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) { | |
| 173 | + // Waking the loop every second regardless would make the editor redraw | |
| 174 | + // forever with nothing to show; the wake is for a change, not for time. | |
| 175 | + a, screen, _ := newWatchedFile(t) | |
| 176 | + a.fileWatchInterval = 5 * time.Millisecond | |
| 177 | + stop := a.watchFiles() | |
| 178 | + defer stop() | |
| 179 | + | |
| 180 | + if wokeWithin(screen, 100*time.Millisecond) { | |
| 181 | + t.Fatal("the event loop was woken though no file changed") | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +// wokeWithin reports whether the watcher posted its wake-up to the screen | |
| 186 | +// before the deadline. | |
| 187 | +func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool { | |
| 188 | + deadline := time.After(within) | |
| 189 | + for { | |
| 190 | + for screen.HasPendingEvent() { | |
| 191 | + if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok { | |
| 192 | + return true | |
| 193 | + } | |
| 194 | + } | |
| 195 | + select { | |
| 196 | + case <-deadline: | |
| 197 | + return false | |
| 198 | + case <-time.After(2 * time.Millisecond): | |
| 199 | + } | |
| 200 | + } | |
| 201 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,201 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + "time" | ||
| 9 | + | ||
| 10 | + "github.com/gdamore/tcell/v2" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +// newWatchedFile returns an editor with a file open in the front window, and | ||
| 14 | +// that file's path. The contents written later are deliberately of a different | ||
| 15 | +// length: on a filesystem that stamps modification times by the second, two | ||
| 16 | +// writes in the same second are told apart by size or not at all. | ||
| 17 | +func newWatchedFile(t *testing.T) (*App, tcell.SimulationScreen, string) { | ||
| 18 | + t.Helper() | ||
| 19 | + | ||
| 20 | + a, screen := newTestApp(t) | ||
| 21 | + path := filepath.Join(t.TempDir(), "main.go") | ||
| 22 | + writeTestFile(t, path, "package main\n") | ||
| 23 | + a.Open(path) | ||
| 24 | + a.tick() // the loop has seen the file as it was opened | ||
| 25 | + return a, screen, path | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func TestAFileRewrittenByAnotherProgramIsReloaded(t *testing.T) { | ||
| 29 | + // This is the whole feature: a formatter, a generator or an agent in | ||
| 30 | + // another terminal rewrites the file, and the window shows it without | ||
| 31 | + // closing and reopening. | ||
| 32 | + a, _, path := newWatchedFile(t) | ||
| 33 | + | ||
| 34 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | ||
| 35 | + a.tick() | ||
| 36 | + | ||
| 37 | + if got := activeEditorText(t, a, path); !strings.Contains(got, "rewritten outside") { | ||
| 38 | + t.Errorf("the window still shows %q after the file changed on disk", got) | ||
| 39 | + } | ||
| 40 | + if got := a.status.Message(); got != "Reloaded main.go" { | ||
| 41 | + t.Errorf("status message = %q, want %q", got, "Reloaded main.go") | ||
| 42 | + } | ||
| 43 | + if title := a.desktop.Active().Title(); title != "main.go" { | ||
| 44 | + t.Errorf("window title = %q after a reload, want %q", title, "main.go") | ||
| 45 | + } | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +func TestAFileWithUnsavedChangesIsKeptWhenTheDiskChanges(t *testing.T) { | ||
| 49 | + // The user's work and the other program's genuinely conflict, and the | ||
| 50 | + // editor is not the one to decide — so it keeps the work and says so. | ||
| 51 | + a, _, path := newWatchedFile(t) | ||
| 52 | + typeText(a, "// mine") | ||
| 53 | + edited := activeEditorText(t, a, path) | ||
| 54 | + | ||
| 55 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | ||
| 56 | + a.tick() | ||
| 57 | + | ||
| 58 | + if got := activeEditorText(t, a, path); got != edited { | ||
| 59 | + t.Errorf("the modified buffer was reloaded: %q, want the unsaved edit %q", got, edited) | ||
| 60 | + } | ||
| 61 | + if got := a.status.Message(); !strings.Contains(got, "changed on disk") { | ||
| 62 | + t.Errorf("status message = %q, want it to say the file changed on disk", got) | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +func TestAChangeOnDiskIsReportedOncePerChange(t *testing.T) { | ||
| 67 | + // The message would otherwise be re-issued on every turn of the loop for as | ||
| 68 | + // long as the window stays modified, hiding everything else the status bar | ||
| 69 | + // has to say. | ||
| 70 | + a, _, path := newWatchedFile(t) | ||
| 71 | + typeText(a, "// mine") | ||
| 72 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | ||
| 73 | + a.tick() | ||
| 74 | + | ||
| 75 | + a.Message("something else") | ||
| 76 | + a.tick() | ||
| 77 | + | ||
| 78 | + if got := a.status.Message(); got != "something else" { | ||
| 79 | + t.Errorf("status message = %q; the change on disk was reported a second time", got) | ||
| 80 | + } | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +func TestTheEditorsOwnSaveIsNotMistakenForAChangeOnDisk(t *testing.T) { | ||
| 84 | + // A save rewrites the file — through a rename, so with a new inode and | ||
| 85 | + // stamp — and must not come round as a reload of the text just written. | ||
| 86 | + a, _, path := newWatchedFile(t) | ||
| 87 | + typeText(a, "// mine") | ||
| 88 | + a.SaveFile() | ||
| 89 | + a.tick() | ||
| 90 | + | ||
| 91 | + if got := a.status.Message(); got != "Saved main.go" { | ||
| 92 | + t.Errorf("status message = %q after a save, want %q", got, "Saved main.go") | ||
| 93 | + } | ||
| 94 | + if got := activeEditorText(t, a, path); !strings.Contains(got, "// mine") { | ||
| 95 | + t.Errorf("the saved text was lost: %q", got) | ||
| 96 | + } | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +func TestAFileThatDisappearsIsLeftAsItWasUntilItComesBack(t *testing.T) { | ||
| 100 | + // Some programs replace a file by deleting it and writing a new one; a | ||
| 101 | + // stat between the two sees nothing. The window keeps what it has, and | ||
| 102 | + // picks the file up when it is there again. | ||
| 103 | + a, _, path := newWatchedFile(t) | ||
| 104 | + | ||
| 105 | + if err := os.Remove(path); err != nil { | ||
| 106 | + t.Fatal(err) | ||
| 107 | + } | ||
| 108 | + a.tick() | ||
| 109 | + if got := activeEditorText(t, a, path); got != "package main\n" { | ||
| 110 | + t.Errorf("the window lost its text when the file went: %q", got) | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + writeTestFile(t, path, "package main\n\n// back again, longer\n") | ||
| 114 | + a.tick() | ||
| 115 | + if got := activeEditorText(t, a, path); !strings.Contains(got, "back again") { | ||
| 116 | + t.Errorf("the window did not pick the file up when it came back: %q", got) | ||
| 117 | + } | ||
| 118 | +} | ||
| 119 | + | ||
| 120 | +func TestAReloadTellsTheLanguageServerWhatTheFileNowSays(t *testing.T) { | ||
| 121 | + // A server answering completions from the text before the formatter ran | ||
| 122 | + // is the same stale-copy problem in another place. | ||
| 123 | + a, server, project := newCodeApp(t) | ||
| 124 | + path := filepath.Join(project, "main.go") | ||
| 125 | + waitForMethod(t, server, "textDocument/didOpen") | ||
| 126 | + a.tick() | ||
| 127 | + before := server.methodCount("textDocument/didChange") | ||
| 128 | + | ||
| 129 | + writeTestFile(t, path, "package main\n\nfunc main() { println() }\n") | ||
| 130 | + a.tick() | ||
| 131 | + | ||
| 132 | + deadline := time.After(2 * time.Second) | ||
| 133 | + for server.methodCount("textDocument/didChange") == before { | ||
| 134 | + select { | ||
| 135 | + case <-deadline: | ||
| 136 | + t.Fatal("the server was never told the reloaded text") | ||
| 137 | + case <-time.After(time.Millisecond): | ||
| 138 | + } | ||
| 139 | + } | ||
| 140 | +} | ||
| 141 | + | ||
| 142 | +func TestAClosedWindowsFileIsNoLongerWatched(t *testing.T) { | ||
| 143 | + a, _, path := newWatchedFile(t) | ||
| 144 | + | ||
| 145 | + a.CloseFile() | ||
| 146 | + a.tick() | ||
| 147 | + writeTestFile(t, path, "package main\n\n// nobody is looking\n") | ||
| 148 | + | ||
| 149 | + if len(a.fileStamps) != 0 { | ||
| 150 | + t.Errorf("%d file(s) still stamped after the only window closed", len(a.fileStamps)) | ||
| 151 | + } | ||
| 152 | + if a.fileWatch.changed() { | ||
| 153 | + t.Error("the watcher would wake the loop for a file no window shows") | ||
| 154 | + } | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +func TestTheWatcherWakesTheLoopWhenAnOpenFileChanges(t *testing.T) { | ||
| 158 | + // Run sits in PollEvent; without this wake the reload would wait for the | ||
| 159 | + // next keystroke, which is the defect being fixed. | ||
| 160 | + a, screen, path := newWatchedFile(t) | ||
| 161 | + a.fileWatchInterval = 5 * time.Millisecond | ||
| 162 | + stop := a.watchFiles() | ||
| 163 | + defer stop() | ||
| 164 | + | ||
| 165 | + writeTestFile(t, path, "package main\n\n// rewritten outside the editor\n") | ||
| 166 | + | ||
| 167 | + if !wokeWithin(screen, time.Second) { | ||
| 168 | + t.Fatal("the event loop was never woken for a file that changed on disk") | ||
| 169 | + } | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +func TestTheWatcherLeavesAnIdleEditorAlone(t *testing.T) { | ||
| 173 | + // Waking the loop every second regardless would make the editor redraw | ||
| 174 | + // forever with nothing to show; the wake is for a change, not for time. | ||
| 175 | + a, screen, _ := newWatchedFile(t) | ||
| 176 | + a.fileWatchInterval = 5 * time.Millisecond | ||
| 177 | + stop := a.watchFiles() | ||
| 178 | + defer stop() | ||
| 179 | + | ||
| 180 | + if wokeWithin(screen, 100*time.Millisecond) { | ||
| 181 | + t.Fatal("the event loop was woken though no file changed") | ||
| 182 | + } | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +// wokeWithin reports whether the watcher posted its wake-up to the screen | ||
| 186 | +// before the deadline. | ||
| 187 | +func wokeWithin(screen tcell.SimulationScreen, within time.Duration) bool { | ||
| 188 | + deadline := time.After(within) | ||
| 189 | + for { | ||
| 190 | + for screen.HasPendingEvent() { | ||
| 191 | + if _, ok := screen.PollEvent().(*tcell.EventInterrupt); ok { | ||
| 192 | + return true | ||
| 193 | + } | ||
| 194 | + } | ||
| 195 | + select { | ||
| 196 | + case <-deadline: | ||
| 197 | + return false | ||
| 198 | + case <-time.After(2 * time.Millisecond): | ||
| 199 | + } | ||
| 200 | + } | ||
| 201 | +} | ||
added
configrepo/README.md +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +# configrepo | |
| 2 | + | |
| 3 | +Copies a project's editor directory — `.turbo-go`, `.turbo-rust` — out of a repository somebody shares, from the URL a forge shows for the directory holding it. | |
| 4 | + | |
| 5 | +``` | |
| 6 | +turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init | |
| 7 | +``` | |
| 8 | + | |
| 9 | +fetches `golang-init/.turbo-go` into the working directory. | |
| 10 | + | |
| 11 | +## Why git, not an API | |
| 12 | + | |
| 13 | +The URL can be on rickub, GitHub, GitLab or Codeberg. Each has a JSON API for reading a directory, each API is different, and rickub's wants a personal access token even to read a public repository. Git is the one thing all of them speak the same way, with no token: the fetch is `git ls-remote` to find the repository and its branches, then `git clone --depth 1 --branch <ref>` into a temporary directory, then a copy. The price is that `git` must be installed, which on the machine of somebody running a terminal IDE it is. `ErrNoGit` is what you get otherwise. | |
| 14 | + | |
| 15 | +## Reading the URL | |
| 16 | + | |
| 17 | +`Parse` understands what the browsers show: | |
| 18 | + | |
| 19 | +| Shape | Forge | | |
| 20 | +| --- | --- | | |
| 21 | +| `https://host/owner/repo/tree/main/dir` | rickub, GitHub | | |
| 22 | +| `https://host/group/sub/repo/-/tree/main/dir` | GitLab — the owner keeps its slashes | | |
| 23 | +| `https://host/owner/repo/src/branch/main/dir` | Codeberg, Forgejo, Gitea — also `src/tag/`, `src/commit/` | | |
| 24 | +| `https://host/owner/repo` | a repository: default branch, root | | |
| 25 | + | |
| 26 | +`blob/` is taken like `tree/`, so a URL to a file inside the directory works too. | |
| 27 | + | |
| 28 | +**A branch with a slash in it** — `feature/x` — cannot be told from the path by the URL alone. `Parse` takes the first segment as the ref and the rest as the path; `Load` then asks the repository for its branches and tags (`git ls-remote`) and `resolveRef` picks the **longest** one that begins `ref/path`, giving the rest back to the path. Nothing matching leaves both as they were and the clone reports the missing branch in git's own words. | |
| 29 | + | |
| 30 | +**Where to clone from.** GitHub, GitLab and Codeberg serve git at the address the browser shows. rickub does not — pages on `rickub.com`, repositories on `git.rickub.com` — so `CloneURLs` offers `https://host/owner/repo.git` and then `https://git.host/owner/repo.git`, and `firstAnswering` takes the first whose `ls-remote` answers. The listing does two jobs at once: telling the candidates apart, and being what a slashed ref is resolved against. | |
| 31 | + | |
| 32 | +## What is copied, and where | |
| 33 | + | |
| 34 | +Inside the checkout, `locate` looks for `<path>/.turbo-go`, or takes `<path>` itself when its last segment already is `.turbo-go`. Every regular file under it is copied with `projectfile.Write` — same mode, same atomic rename, directories made on the way — so a loaded configuration is created exactly as the editor creates one itself. Symbolic links and anything else that is not a regular file stay behind: a configuration is files. | |
| 35 | + | |
| 36 | +The destination is `dest/.turbo-go`, `dest` being the working directory. **If it already exists, nothing is done and `ErrExists` says so.** A project's configuration is a decision the project made; the editor does not overwrite it because a URL was typed. Move it away first. | |
| 37 | + | |
| 38 | +`GIT_TERMINAL_PROMPT=0` is set on every git call: this runs from a command line that asked for a URL, and a private repository is reported, not waited on. | |
| 39 | + | |
| 40 | +## Public API | |
| 41 | + | |
| 42 | +| Name | What it does | | |
| 43 | +| --- | --- | | |
| 44 | +| `Parse(url) (Source, error)` | Reads a forge's URL into host, owner, repo, ref, path | | |
| 45 | +| `Source.CloneURLs() []string` | The clone addresses to try, most likely first | | |
| 46 | +| `Load(ctx, profile, url, dest) (Result, error)` | Parse, find the repository, clone the ref, copy the editor's directory into dest | | |
| 47 | +| `Result` | The remote that answered, the ref, the directory created, the files in it | | |
| 48 | +| `ErrNoGit`, `ErrExists`, `ErrNotFound` | No git; the project already has the directory; the repository has none where the URL points | | |
| 49 | + | |
| 50 | +## Tests | |
| 51 | + | |
| 52 | +```sh | |
| 53 | +go test ./configrepo/ | |
| 54 | +TURBO_CORE_NETWORK=1 go test ./configrepo/ # also the real thing, against rickub | |
| 55 | +``` | |
| 56 | + | |
| 57 | +`sampleRepo` builds a repository on disk with `git init` — two branches, one of them `feature/x` — and `loadFrom` is pointed at it as a remote, which git accepts as readily as a URL. The network test is skipped unless asked for: the suite must pass on a machine with no network. | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +# configrepo | ||
| 2 | + | ||
| 3 | +Copies a project's editor directory — `.turbo-go`, `.turbo-rust` — out of a repository somebody shares, from the URL a forge shows for the directory holding it. | ||
| 4 | + | ||
| 5 | +``` | ||
| 6 | +turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init | ||
| 7 | +``` | ||
| 8 | + | ||
| 9 | +fetches `golang-init/.turbo-go` into the working directory. | ||
| 10 | + | ||
| 11 | +## Why git, not an API | ||
| 12 | + | ||
| 13 | +The URL can be on rickub, GitHub, GitLab or Codeberg. Each has a JSON API for reading a directory, each API is different, and rickub's wants a personal access token even to read a public repository. Git is the one thing all of them speak the same way, with no token: the fetch is `git ls-remote` to find the repository and its branches, then `git clone --depth 1 --branch <ref>` into a temporary directory, then a copy. The price is that `git` must be installed, which on the machine of somebody running a terminal IDE it is. `ErrNoGit` is what you get otherwise. | ||
| 14 | + | ||
| 15 | +## Reading the URL | ||
| 16 | + | ||
| 17 | +`Parse` understands what the browsers show: | ||
| 18 | + | ||
| 19 | +| Shape | Forge | | ||
| 20 | +| --- | --- | | ||
| 21 | +| `https://host/owner/repo/tree/main/dir` | rickub, GitHub | | ||
| 22 | +| `https://host/group/sub/repo/-/tree/main/dir` | GitLab — the owner keeps its slashes | | ||
| 23 | +| `https://host/owner/repo/src/branch/main/dir` | Codeberg, Forgejo, Gitea — also `src/tag/`, `src/commit/` | | ||
| 24 | +| `https://host/owner/repo` | a repository: default branch, root | | ||
| 25 | + | ||
| 26 | +`blob/` is taken like `tree/`, so a URL to a file inside the directory works too. | ||
| 27 | + | ||
| 28 | +**A branch with a slash in it** — `feature/x` — cannot be told from the path by the URL alone. `Parse` takes the first segment as the ref and the rest as the path; `Load` then asks the repository for its branches and tags (`git ls-remote`) and `resolveRef` picks the **longest** one that begins `ref/path`, giving the rest back to the path. Nothing matching leaves both as they were and the clone reports the missing branch in git's own words. | ||
| 29 | + | ||
| 30 | +**Where to clone from.** GitHub, GitLab and Codeberg serve git at the address the browser shows. rickub does not — pages on `rickub.com`, repositories on `git.rickub.com` — so `CloneURLs` offers `https://host/owner/repo.git` and then `https://git.host/owner/repo.git`, and `firstAnswering` takes the first whose `ls-remote` answers. The listing does two jobs at once: telling the candidates apart, and being what a slashed ref is resolved against. | ||
| 31 | + | ||
| 32 | +## What is copied, and where | ||
| 33 | + | ||
| 34 | +Inside the checkout, `locate` looks for `<path>/.turbo-go`, or takes `<path>` itself when its last segment already is `.turbo-go`. Every regular file under it is copied with `projectfile.Write` — same mode, same atomic rename, directories made on the way — so a loaded configuration is created exactly as the editor creates one itself. Symbolic links and anything else that is not a regular file stay behind: a configuration is files. | ||
| 35 | + | ||
| 36 | +The destination is `dest/.turbo-go`, `dest` being the working directory. **If it already exists, nothing is done and `ErrExists` says so.** A project's configuration is a decision the project made; the editor does not overwrite it because a URL was typed. Move it away first. | ||
| 37 | + | ||
| 38 | +`GIT_TERMINAL_PROMPT=0` is set on every git call: this runs from a command line that asked for a URL, and a private repository is reported, not waited on. | ||
| 39 | + | ||
| 40 | +## Public API | ||
| 41 | + | ||
| 42 | +| Name | What it does | | ||
| 43 | +| --- | --- | | ||
| 44 | +| `Parse(url) (Source, error)` | Reads a forge's URL into host, owner, repo, ref, path | | ||
| 45 | +| `Source.CloneURLs() []string` | The clone addresses to try, most likely first | | ||
| 46 | +| `Load(ctx, profile, url, dest) (Result, error)` | Parse, find the repository, clone the ref, copy the editor's directory into dest | | ||
| 47 | +| `Result` | The remote that answered, the ref, the directory created, the files in it | | ||
| 48 | +| `ErrNoGit`, `ErrExists`, `ErrNotFound` | No git; the project already has the directory; the repository has none where the URL points | | ||
| 49 | + | ||
| 50 | +## Tests | ||
| 51 | + | ||
| 52 | +```sh | ||
| 53 | +go test ./configrepo/ | ||
| 54 | +TURBO_CORE_NETWORK=1 go test ./configrepo/ # also the real thing, against rickub | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +`sampleRepo` builds a repository on disk with `git init` — two branches, one of them `feature/x` — and `loadFrom` is pointed at it as a remote, which git accepts as readily as a URL. The network test is skipped unless asked for: the suite must pass on a machine with no network. | ||
added
configrepo/configrepo.go +231 -0 | new file mode 100644 | ||
| @@ -0,0 +1,231 @@ | ||
| 1 | +// Package configrepo copies a project's editor directory — .turbo-go, | |
| 2 | +// .turbo-rust — out of a repository somebody shares, from the URL a forge | |
| 3 | +// shows for the directory holding it. | |
| 4 | +// | |
| 5 | +// A team keeps ready-made configurations in a repository: a directory per | |
| 6 | +// kind of project, each with the editor's own directory inside. Rather than | |
| 7 | +// cloning it and copying by hand, an editor is told the URL: | |
| 8 | +// | |
| 9 | +// turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init | |
| 10 | +// | |
| 11 | +// and it fetches golang-init/.turbo-go into the working directory. The URL is | |
| 12 | +// the one in the browser's address bar — rickub, GitHub, GitLab and Codeberg | |
| 13 | +// are all understood — and the fetch is a shallow git clone, because git is | |
| 14 | +// the one thing every forge speaks the same way, with no token and no API. | |
| 15 | +package configrepo | |
| 16 | + | |
| 17 | +import ( | |
| 18 | + "context" | |
| 19 | + "errors" | |
| 20 | + "fmt" | |
| 21 | + "net/url" | |
| 22 | + "os" | |
| 23 | + "os/exec" | |
| 24 | + "path/filepath" | |
| 25 | + "strings" | |
| 26 | + | |
| 27 | + "rickub.com/turbo-editors/turbo-core/profile" | |
| 28 | +) | |
| 29 | + | |
| 30 | +// ErrNoGit is returned when git is not installed: the fetch is a clone, and | |
| 31 | +// there is nothing else to do it with. | |
| 32 | +var ErrNoGit = errors.New("configrepo: git is not installed") | |
| 33 | + | |
| 34 | +// ErrExists is returned when the working directory already has the editor's | |
| 35 | +// directory. A project's configuration is a decision the project made, and | |
| 36 | +// loading another over it is not something to do without being asked twice. | |
| 37 | +var ErrExists = errors.New("configrepo: the project already has a configuration directory") | |
| 38 | + | |
| 39 | +// ErrNotFound is returned when the repository is there but the editor's | |
| 40 | +// directory is not where the URL says. | |
| 41 | +var ErrNotFound = errors.New("configrepo: no configuration directory at that path") | |
| 42 | + | |
| 43 | +// Source is a directory in a repository, as a forge's URL names it. | |
| 44 | +// | |
| 45 | +// src, err := configrepo.Parse("https://github.com/acme/configs/tree/main/go-service") | |
| 46 | +// // Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"} | |
| 47 | +type Source struct { | |
| 48 | + Host string // the forge, as in the URL | |
| 49 | + Owner string // the user or organisation — or a GitLab group, slashes and all | |
| 50 | + Repo string // the repository, without .git | |
| 51 | + Ref string // the first segment after the tree marker; "" for the default branch | |
| 52 | + Path string // the directory inside the repository; "" for its root | |
| 53 | +} | |
| 54 | + | |
| 55 | +// Parse reads a forge's URL for a directory, a file, or a repository. | |
| 56 | +// | |
| 57 | +// The shapes understood are the ones the browsers show: | |
| 58 | +// | |
| 59 | +// https://rickub.com/owner/repo/tree/main/dir rickub, GitHub | |
| 60 | +// https://gitlab.com/group/sub/repo/-/tree/main/dir GitLab | |
| 61 | +// https://codeberg.org/owner/repo/src/branch/main/dir Codeberg, Forgejo, Gitea | |
| 62 | +// https://github.com/owner/repo a repository: its default branch, its root | |
| 63 | +// | |
| 64 | +// A branch name with a slash in it cannot be told from the path here — the | |
| 65 | +// URL does not say where one ends — so Ref is the first segment and the rest | |
| 66 | +// is Path; Load asks the repository for its branches and settles it there. | |
| 67 | +func Parse(rawURL string) (Source, error) { | |
| 68 | + parsed, err := url.Parse(strings.TrimSpace(rawURL)) | |
| 69 | + if err != nil { | |
| 70 | + return Source{}, fmt.Errorf("configrepo: %q is not a URL: %w", rawURL, err) | |
| 71 | + } | |
| 72 | + if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" { | |
| 73 | + return Source{}, fmt.Errorf("configrepo: %q is not an https URL of a repository", rawURL) | |
| 74 | + } | |
| 75 | + | |
| 76 | + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") | |
| 77 | + if len(segments) < 2 || segments[0] == "" || segments[1] == "" { | |
| 78 | + return Source{}, fmt.Errorf("configrepo: %q names no repository (want https://host/owner/repo/…)", rawURL) | |
| 79 | + } | |
| 80 | + | |
| 81 | + repoAt, rest := treeMarker(segments) | |
| 82 | + src := Source{ | |
| 83 | + Host: parsed.Host, | |
| 84 | + Owner: strings.Join(segments[:repoAt], "/"), | |
| 85 | + Repo: strings.TrimSuffix(segments[repoAt], ".git"), | |
| 86 | + } | |
| 87 | + if len(rest) > 0 { | |
| 88 | + src.Ref = rest[0] | |
| 89 | + src.Path = strings.Join(rest[1:], "/") | |
| 90 | + } | |
| 91 | + return src, nil | |
| 92 | +} | |
| 93 | + | |
| 94 | +// treeMarker finds where the repository ends and the tree begins: the index | |
| 95 | +// of the repository segment, and the segments after the marker, starting with | |
| 96 | +// the ref. With no marker the repository is the last segment and there is no | |
| 97 | +// tree. | |
| 98 | +func treeMarker(segments []string) (repoAt int, rest []string) { | |
| 99 | + for i := 2; i < len(segments); i++ { | |
| 100 | + switch segments[i] { | |
| 101 | + case "tree", "blob": | |
| 102 | + return i - 1, segments[i+1:] | |
| 103 | + case "-": | |
| 104 | + if i+1 < len(segments) && (segments[i+1] == "tree" || segments[i+1] == "blob") { | |
| 105 | + return i - 1, segments[i+2:] | |
| 106 | + } | |
| 107 | + case "src": | |
| 108 | + // Gitea and its forks say what kind of ref follows: src/branch/main, | |
| 109 | + // src/tag/v1, src/commit/abc123. The kind is not part of the name. | |
| 110 | + if i+1 < len(segments) && (segments[i+1] == "branch" || segments[i+1] == "tag" || segments[i+1] == "commit") { | |
| 111 | + return i - 1, segments[i+2:] | |
| 112 | + } | |
| 113 | + } | |
| 114 | + } | |
| 115 | + return len(segments) - 1, nil | |
| 116 | +} | |
| 117 | + | |
| 118 | +// CloneURLs returns where to clone the repository from, most likely first. | |
| 119 | +// | |
| 120 | +// GitHub, GitLab and Codeberg serve git at the address the browser shows. | |
| 121 | +// rickub does not: its pages are on rickub.com and its repositories on | |
| 122 | +// git.rickub.com, so a second candidate puts "git." in front of the host. | |
| 123 | +// Load tries them in order and stops at the first that answers. | |
| 124 | +func (s Source) CloneURLs() []string { | |
| 125 | + path := "/" + s.Owner + "/" + s.Repo + ".git" | |
| 126 | + return []string{ | |
| 127 | + "https://" + s.Host + path, | |
| 128 | + "https://git." + s.Host + path, | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +// Result says what Load did. | |
| 133 | +type Result struct { | |
| 134 | + Remote string // the clone URL that answered | |
| 135 | + Ref string // the branch or tag the files came from; "" for the default | |
| 136 | + Dir string // the directory created | |
| 137 | + Files []string // what it holds, relative to Dir, in walk order | |
| 138 | +} | |
| 139 | + | |
| 140 | +// Load fetches the editor's directory from a repository into a project. | |
| 141 | +// | |
| 142 | +// The URL names a directory; the editor's own directory — p.ProjectDir(), | |
| 143 | +// ".turbo-go" for Turbo Go — is looked for inside it, or the URL may name | |
| 144 | +// that directory itself. It is copied into dest as dest/.turbo-go, files | |
| 145 | +// and subdirectories alike. dest is normally the working directory. | |
| 146 | +// | |
| 147 | +// It refuses with ErrExists when dest already has such a directory, with | |
| 148 | +// ErrNoGit when there is no git to clone with, and with ErrNotFound when the | |
| 149 | +// repository holds no such directory where the URL points. | |
| 150 | +// | |
| 151 | +// result, err := configrepo.Load(ctx, golang.Profile(), url, ".") | |
| 152 | +// if err != nil { | |
| 153 | +// return err | |
| 154 | +// } | |
| 155 | +// fmt.Printf("Copied %s (%d files)\n", result.Dir, len(result.Files)) | |
| 156 | +func Load(ctx context.Context, p profile.Profile, rawURL, dest string) (Result, error) { | |
| 157 | + src, err := Parse(rawURL) | |
| 158 | + if err != nil { | |
| 159 | + return Result{}, err | |
| 160 | + } | |
| 161 | + return loadFrom(ctx, p, src.CloneURLs(), src, dest) | |
| 162 | +} | |
| 163 | + | |
| 164 | +// loadFrom is Load once the URL has been read: it takes the remotes to try, | |
| 165 | +// so that a test can point it at a repository on disk. | |
| 166 | +func loadFrom(ctx context.Context, p profile.Profile, remotes []string, src Source, dest string) (Result, error) { | |
| 167 | + target := filepath.Join(dest, p.ProjectDir()) | |
| 168 | + g, err := ready(target) | |
| 169 | + if err != nil { | |
| 170 | + return Result{}, err | |
| 171 | + } | |
| 172 | + | |
| 173 | + checkout, err := os.MkdirTemp("", "configrepo-*") | |
| 174 | + if err != nil { | |
| 175 | + return Result{}, fmt.Errorf("configrepo: %w", err) | |
| 176 | + } | |
| 177 | + defer os.RemoveAll(checkout) | |
| 178 | + | |
| 179 | + remote, ref, path, err := g.fetch(ctx, remotes, src, checkout) | |
| 180 | + if err != nil { | |
| 181 | + return Result{}, err | |
| 182 | + } | |
| 183 | + files, err := install(checkout, path, p.ProjectDir(), remote, target) | |
| 184 | + if err != nil { | |
| 185 | + return Result{}, err | |
| 186 | + } | |
| 187 | + return Result{Remote: remote, Ref: ref, Dir: target, Files: files}, nil | |
| 188 | +} | |
| 189 | + | |
| 190 | +// ready checks what has to be true before anything is fetched — no | |
| 191 | +// configuration directory in the way, a git to fetch with — and returns that | |
| 192 | +// git. Both are asked first because both are answered without the network. | |
| 193 | +func ready(target string) (git, error) { | |
| 194 | + if _, err := os.Stat(target); err == nil { | |
| 195 | + return git{}, fmt.Errorf("%w: %s", ErrExists, target) | |
| 196 | + } | |
| 197 | + gitPath, err := exec.LookPath("git") | |
| 198 | + if err != nil { | |
| 199 | + return git{}, ErrNoGit | |
| 200 | + } | |
| 201 | + return git{path: gitPath}, nil | |
| 202 | +} | |
| 203 | + | |
| 204 | +// install copies the editor's directory out of a checkout into the project, | |
| 205 | +// and returns what was copied. | |
| 206 | +func install(checkout, path, projectDir, remote, target string) ([]string, error) { | |
| 207 | + from, err := locate(checkout, path, projectDir, remote) | |
| 208 | + if err != nil { | |
| 209 | + return nil, err | |
| 210 | + } | |
| 211 | + return copyTree(from, target) | |
| 212 | +} | |
| 213 | + | |
| 214 | +// locate returns the editor's directory inside a checkout: at path/.turbo-go, | |
| 215 | +// or at path itself when the URL already named it. | |
| 216 | +func locate(checkout, path, projectDir, remote string) (string, error) { | |
| 217 | + candidate := filepath.Join(checkout, filepath.FromSlash(path)) | |
| 218 | + if filepath.Base(candidate) != projectDir { | |
| 219 | + candidate = filepath.Join(candidate, projectDir) | |
| 220 | + } | |
| 221 | + | |
| 222 | + info, err := os.Stat(candidate) | |
| 223 | + if err != nil || !info.IsDir() { | |
| 224 | + where := path | |
| 225 | + if where == "" { | |
| 226 | + where = "the root" | |
| 227 | + } | |
| 228 | + return "", fmt.Errorf("%w: %s at %s of %s", ErrNotFound, projectDir, where, remote) | |
| 229 | + } | |
| 230 | + return candidate, nil | |
| 231 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,231 @@ | |||
| 1 | +// Package configrepo copies a project's editor directory — .turbo-go, | ||
| 2 | +// .turbo-rust — out of a repository somebody shares, from the URL a forge | ||
| 3 | +// shows for the directory holding it. | ||
| 4 | +// | ||
| 5 | +// A team keeps ready-made configurations in a repository: a directory per | ||
| 6 | +// kind of project, each with the editor's own directory inside. Rather than | ||
| 7 | +// cloning it and copying by hand, an editor is told the URL: | ||
| 8 | +// | ||
| 9 | +// turbo-go -load-config https://rickub.com/turbo-editors/configs/tree/main/golang-init | ||
| 10 | +// | ||
| 11 | +// and it fetches golang-init/.turbo-go into the working directory. The URL is | ||
| 12 | +// the one in the browser's address bar — rickub, GitHub, GitLab and Codeberg | ||
| 13 | +// are all understood — and the fetch is a shallow git clone, because git is | ||
| 14 | +// the one thing every forge speaks the same way, with no token and no API. | ||
| 15 | +package configrepo | ||
| 16 | + | ||
| 17 | +import ( | ||
| 18 | + "context" | ||
| 19 | + "errors" | ||
| 20 | + "fmt" | ||
| 21 | + "net/url" | ||
| 22 | + "os" | ||
| 23 | + "os/exec" | ||
| 24 | + "path/filepath" | ||
| 25 | + "strings" | ||
| 26 | + | ||
| 27 | + "rickub.com/turbo-editors/turbo-core/profile" | ||
| 28 | +) | ||
| 29 | + | ||
| 30 | +// ErrNoGit is returned when git is not installed: the fetch is a clone, and | ||
| 31 | +// there is nothing else to do it with. | ||
| 32 | +var ErrNoGit = errors.New("configrepo: git is not installed") | ||
| 33 | + | ||
| 34 | +// ErrExists is returned when the working directory already has the editor's | ||
| 35 | +// directory. A project's configuration is a decision the project made, and | ||
| 36 | +// loading another over it is not something to do without being asked twice. | ||
| 37 | +var ErrExists = errors.New("configrepo: the project already has a configuration directory") | ||
| 38 | + | ||
| 39 | +// ErrNotFound is returned when the repository is there but the editor's | ||
| 40 | +// directory is not where the URL says. | ||
| 41 | +var ErrNotFound = errors.New("configrepo: no configuration directory at that path") | ||
| 42 | + | ||
| 43 | +// Source is a directory in a repository, as a forge's URL names it. | ||
| 44 | +// | ||
| 45 | +// src, err := configrepo.Parse("https://github.com/acme/configs/tree/main/go-service") | ||
| 46 | +// // Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"} | ||
| 47 | +type Source struct { | ||
| 48 | + Host string // the forge, as in the URL | ||
| 49 | + Owner string // the user or organisation — or a GitLab group, slashes and all | ||
| 50 | + Repo string // the repository, without .git | ||
| 51 | + Ref string // the first segment after the tree marker; "" for the default branch | ||
| 52 | + Path string // the directory inside the repository; "" for its root | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// Parse reads a forge's URL for a directory, a file, or a repository. | ||
| 56 | +// | ||
| 57 | +// The shapes understood are the ones the browsers show: | ||
| 58 | +// | ||
| 59 | +// https://rickub.com/owner/repo/tree/main/dir rickub, GitHub | ||
| 60 | +// https://gitlab.com/group/sub/repo/-/tree/main/dir GitLab | ||
| 61 | +// https://codeberg.org/owner/repo/src/branch/main/dir Codeberg, Forgejo, Gitea | ||
| 62 | +// https://github.com/owner/repo a repository: its default branch, its root | ||
| 63 | +// | ||
| 64 | +// A branch name with a slash in it cannot be told from the path here — the | ||
| 65 | +// URL does not say where one ends — so Ref is the first segment and the rest | ||
| 66 | +// is Path; Load asks the repository for its branches and settles it there. | ||
| 67 | +func Parse(rawURL string) (Source, error) { | ||
| 68 | + parsed, err := url.Parse(strings.TrimSpace(rawURL)) | ||
| 69 | + if err != nil { | ||
| 70 | + return Source{}, fmt.Errorf("configrepo: %q is not a URL: %w", rawURL, err) | ||
| 71 | + } | ||
| 72 | + if (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" { | ||
| 73 | + return Source{}, fmt.Errorf("configrepo: %q is not an https URL of a repository", rawURL) | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") | ||
| 77 | + if len(segments) < 2 || segments[0] == "" || segments[1] == "" { | ||
| 78 | + return Source{}, fmt.Errorf("configrepo: %q names no repository (want https://host/owner/repo/…)", rawURL) | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + repoAt, rest := treeMarker(segments) | ||
| 82 | + src := Source{ | ||
| 83 | + Host: parsed.Host, | ||
| 84 | + Owner: strings.Join(segments[:repoAt], "/"), | ||
| 85 | + Repo: strings.TrimSuffix(segments[repoAt], ".git"), | ||
| 86 | + } | ||
| 87 | + if len(rest) > 0 { | ||
| 88 | + src.Ref = rest[0] | ||
| 89 | + src.Path = strings.Join(rest[1:], "/") | ||
| 90 | + } | ||
| 91 | + return src, nil | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// treeMarker finds where the repository ends and the tree begins: the index | ||
| 95 | +// of the repository segment, and the segments after the marker, starting with | ||
| 96 | +// the ref. With no marker the repository is the last segment and there is no | ||
| 97 | +// tree. | ||
| 98 | +func treeMarker(segments []string) (repoAt int, rest []string) { | ||
| 99 | + for i := 2; i < len(segments); i++ { | ||
| 100 | + switch segments[i] { | ||
| 101 | + case "tree", "blob": | ||
| 102 | + return i - 1, segments[i+1:] | ||
| 103 | + case "-": | ||
| 104 | + if i+1 < len(segments) && (segments[i+1] == "tree" || segments[i+1] == "blob") { | ||
| 105 | + return i - 1, segments[i+2:] | ||
| 106 | + } | ||
| 107 | + case "src": | ||
| 108 | + // Gitea and its forks say what kind of ref follows: src/branch/main, | ||
| 109 | + // src/tag/v1, src/commit/abc123. The kind is not part of the name. | ||
| 110 | + if i+1 < len(segments) && (segments[i+1] == "branch" || segments[i+1] == "tag" || segments[i+1] == "commit") { | ||
| 111 | + return i - 1, segments[i+2:] | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + } | ||
| 115 | + return len(segments) - 1, nil | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +// CloneURLs returns where to clone the repository from, most likely first. | ||
| 119 | +// | ||
| 120 | +// GitHub, GitLab and Codeberg serve git at the address the browser shows. | ||
| 121 | +// rickub does not: its pages are on rickub.com and its repositories on | ||
| 122 | +// git.rickub.com, so a second candidate puts "git." in front of the host. | ||
| 123 | +// Load tries them in order and stops at the first that answers. | ||
| 124 | +func (s Source) CloneURLs() []string { | ||
| 125 | + path := "/" + s.Owner + "/" + s.Repo + ".git" | ||
| 126 | + return []string{ | ||
| 127 | + "https://" + s.Host + path, | ||
| 128 | + "https://git." + s.Host + path, | ||
| 129 | + } | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +// Result says what Load did. | ||
| 133 | +type Result struct { | ||
| 134 | + Remote string // the clone URL that answered | ||
| 135 | + Ref string // the branch or tag the files came from; "" for the default | ||
| 136 | + Dir string // the directory created | ||
| 137 | + Files []string // what it holds, relative to Dir, in walk order | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +// Load fetches the editor's directory from a repository into a project. | ||
| 141 | +// | ||
| 142 | +// The URL names a directory; the editor's own directory — p.ProjectDir(), | ||
| 143 | +// ".turbo-go" for Turbo Go — is looked for inside it, or the URL may name | ||
| 144 | +// that directory itself. It is copied into dest as dest/.turbo-go, files | ||
| 145 | +// and subdirectories alike. dest is normally the working directory. | ||
| 146 | +// | ||
| 147 | +// It refuses with ErrExists when dest already has such a directory, with | ||
| 148 | +// ErrNoGit when there is no git to clone with, and with ErrNotFound when the | ||
| 149 | +// repository holds no such directory where the URL points. | ||
| 150 | +// | ||
| 151 | +// result, err := configrepo.Load(ctx, golang.Profile(), url, ".") | ||
| 152 | +// if err != nil { | ||
| 153 | +// return err | ||
| 154 | +// } | ||
| 155 | +// fmt.Printf("Copied %s (%d files)\n", result.Dir, len(result.Files)) | ||
| 156 | +func Load(ctx context.Context, p profile.Profile, rawURL, dest string) (Result, error) { | ||
| 157 | + src, err := Parse(rawURL) | ||
| 158 | + if err != nil { | ||
| 159 | + return Result{}, err | ||
| 160 | + } | ||
| 161 | + return loadFrom(ctx, p, src.CloneURLs(), src, dest) | ||
| 162 | +} | ||
| 163 | + | ||
| 164 | +// loadFrom is Load once the URL has been read: it takes the remotes to try, | ||
| 165 | +// so that a test can point it at a repository on disk. | ||
| 166 | +func loadFrom(ctx context.Context, p profile.Profile, remotes []string, src Source, dest string) (Result, error) { | ||
| 167 | + target := filepath.Join(dest, p.ProjectDir()) | ||
| 168 | + g, err := ready(target) | ||
| 169 | + if err != nil { | ||
| 170 | + return Result{}, err | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + checkout, err := os.MkdirTemp("", "configrepo-*") | ||
| 174 | + if err != nil { | ||
| 175 | + return Result{}, fmt.Errorf("configrepo: %w", err) | ||
| 176 | + } | ||
| 177 | + defer os.RemoveAll(checkout) | ||
| 178 | + | ||
| 179 | + remote, ref, path, err := g.fetch(ctx, remotes, src, checkout) | ||
| 180 | + if err != nil { | ||
| 181 | + return Result{}, err | ||
| 182 | + } | ||
| 183 | + files, err := install(checkout, path, p.ProjectDir(), remote, target) | ||
| 184 | + if err != nil { | ||
| 185 | + return Result{}, err | ||
| 186 | + } | ||
| 187 | + return Result{Remote: remote, Ref: ref, Dir: target, Files: files}, nil | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +// ready checks what has to be true before anything is fetched — no | ||
| 191 | +// configuration directory in the way, a git to fetch with — and returns that | ||
| 192 | +// git. Both are asked first because both are answered without the network. | ||
| 193 | +func ready(target string) (git, error) { | ||
| 194 | + if _, err := os.Stat(target); err == nil { | ||
| 195 | + return git{}, fmt.Errorf("%w: %s", ErrExists, target) | ||
| 196 | + } | ||
| 197 | + gitPath, err := exec.LookPath("git") | ||
| 198 | + if err != nil { | ||
| 199 | + return git{}, ErrNoGit | ||
| 200 | + } | ||
| 201 | + return git{path: gitPath}, nil | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +// install copies the editor's directory out of a checkout into the project, | ||
| 205 | +// and returns what was copied. | ||
| 206 | +func install(checkout, path, projectDir, remote, target string) ([]string, error) { | ||
| 207 | + from, err := locate(checkout, path, projectDir, remote) | ||
| 208 | + if err != nil { | ||
| 209 | + return nil, err | ||
| 210 | + } | ||
| 211 | + return copyTree(from, target) | ||
| 212 | +} | ||
| 213 | + | ||
| 214 | +// locate returns the editor's directory inside a checkout: at path/.turbo-go, | ||
| 215 | +// or at path itself when the URL already named it. | ||
| 216 | +func locate(checkout, path, projectDir, remote string) (string, error) { | ||
| 217 | + candidate := filepath.Join(checkout, filepath.FromSlash(path)) | ||
| 218 | + if filepath.Base(candidate) != projectDir { | ||
| 219 | + candidate = filepath.Join(candidate, projectDir) | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + info, err := os.Stat(candidate) | ||
| 223 | + if err != nil || !info.IsDir() { | ||
| 224 | + where := path | ||
| 225 | + if where == "" { | ||
| 226 | + where = "the root" | ||
| 227 | + } | ||
| 228 | + return "", fmt.Errorf("%w: %s at %s of %s", ErrNotFound, projectDir, where, remote) | ||
| 229 | + } | ||
| 230 | + return candidate, nil | ||
| 231 | +} | ||
added
configrepo/configrepo_test.go +314 -0 | new file mode 100644 | ||
| @@ -0,0 +1,314 @@ | ||
| 1 | +package configrepo | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "os" | |
| 7 | + "os/exec" | |
| 8 | + "path/filepath" | |
| 9 | + "reflect" | |
| 10 | + "strings" | |
| 11 | + "testing" | |
| 12 | + | |
| 13 | + "rickub.com/turbo-editors/turbo-core/profile" | |
| 14 | +) | |
| 15 | + | |
| 16 | +func TestParseReadsTheURLsTheForgesShow(t *testing.T) { | |
| 17 | + cases := []struct { | |
| 18 | + url string | |
| 19 | + want Source | |
| 20 | + }{ | |
| 21 | + {"https://rickub.com/turbo-editors/configs/tree/main/golang-init", | |
| 22 | + Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init"}}, | |
| 23 | + {"https://github.com/acme/configs/tree/main/go/service", | |
| 24 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go/service"}}, | |
| 25 | + {"https://gitlab.com/acme/platform/configs/-/tree/main/go-service", | |
| 26 | + Source{Host: "gitlab.com", Owner: "acme/platform", Repo: "configs", Ref: "main", Path: "go-service"}}, | |
| 27 | + {"https://codeberg.org/acme/configs/src/branch/main/go-service", | |
| 28 | + Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}}, | |
| 29 | + {"https://codeberg.org/acme/configs/src/tag/v1.0.0/go-service", | |
| 30 | + Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "v1.0.0", Path: "go-service"}}, | |
| 31 | + {"https://github.com/acme/configs", | |
| 32 | + Source{Host: "github.com", Owner: "acme", Repo: "configs"}}, | |
| 33 | + {"https://github.com/acme/configs.git", | |
| 34 | + Source{Host: "github.com", Owner: "acme", Repo: "configs"}}, | |
| 35 | + {"https://github.com/acme/configs/tree/main", | |
| 36 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main"}}, | |
| 37 | + {"https://github.com/acme/configs/tree/main/go-service/", | |
| 38 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}}, | |
| 39 | + {"https://github.com/acme/configs/blob/main/go-service/.turbo-go/settings.toml", | |
| 40 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service/.turbo-go/settings.toml"}}, | |
| 41 | + {" https://rickub.com/turbo-editors/configs/tree/main/golang-init-with-agents\n", | |
| 42 | + Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init-with-agents"}}, | |
| 43 | + } | |
| 44 | + for _, c := range cases { | |
| 45 | + got, err := Parse(c.url) | |
| 46 | + if err != nil { | |
| 47 | + t.Errorf("Parse(%q) error = %v", c.url, err) | |
| 48 | + continue | |
| 49 | + } | |
| 50 | + if got != c.want { | |
| 51 | + t.Errorf("Parse(%q) = %+v, want %+v", c.url, got, c.want) | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +func TestParseRefusesWhatIsNotARepository(t *testing.T) { | |
| 57 | + for _, bad := range []string{"", "configs", "https://rickub.com", "https://rickub.com/turbo-editors", "ftp://x/y/z", "not a url at all ://"} { | |
| 58 | + if _, err := Parse(bad); err == nil { | |
| 59 | + t.Errorf("Parse(%q) accepted something that names no repository", bad) | |
| 60 | + } | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +func TestCloneURLsTryTheHostThenItsGitSubdomain(t *testing.T) { | |
| 65 | + // rickub's pages are on rickub.com and its repositories on git.rickub.com. | |
| 66 | + src := Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs"} | |
| 67 | + | |
| 68 | + want := []string{"https://rickub.com/turbo-editors/configs.git", "https://git.rickub.com/turbo-editors/configs.git"} | |
| 69 | + if got := src.CloneURLs(); !reflect.DeepEqual(got, want) { | |
| 70 | + t.Errorf("CloneURLs() = %v, want %v", got, want) | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +func TestResolveRefSettlesABranchWithASlashInIt(t *testing.T) { | |
| 75 | + refs := []string{"main", "feature/x", "feature/x/deeper", "v1.0.0"} | |
| 76 | + cases := []struct { | |
| 77 | + ref, path, wantRef, wantPath string | |
| 78 | + }{ | |
| 79 | + {"main", "golang-init", "main", "golang-init"}, | |
| 80 | + {"feature", "x/golang-init", "feature/x", "golang-init"}, | |
| 81 | + {"feature", "x/deeper/golang-init", "feature/x/deeper", "golang-init"}, | |
| 82 | + {"feature", "x", "feature/x", ""}, | |
| 83 | + {"v1.0.0", "", "v1.0.0", ""}, | |
| 84 | + {"nowhere", "golang-init", "nowhere", "golang-init"}, // left alone; the clone will say | |
| 85 | + {"", "", "", ""}, // the default branch | |
| 86 | + } | |
| 87 | + for _, c := range cases { | |
| 88 | + ref, path := resolveRef(refs, c.ref, c.path) | |
| 89 | + if ref != c.wantRef || path != c.wantPath { | |
| 90 | + t.Errorf("resolveRef(%q, %q) = %q, %q; want %q, %q", c.ref, c.path, ref, path, c.wantRef, c.wantPath) | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +func TestParseRefsReadsBranchesAndTagsAndDropsPeeledTags(t *testing.T) { | |
| 96 | + output := "abc\tHEAD\nabc\trefs/heads/main\ndef\trefs/heads/feature/x\n123\trefs/tags/v1.0.0\n456\trefs/tags/v1.0.0^{}\n" | |
| 97 | + | |
| 98 | + want := []string{"main", "feature/x", "v1.0.0"} | |
| 99 | + if got := parseRefs(output); !reflect.DeepEqual(got, want) { | |
| 100 | + t.Errorf("parseRefs() = %v, want %v", got, want) | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +// testProfile is an editor whose project directory is .turbo-test. | |
| 105 | +func testProfile() profile.Profile { | |
| 106 | + return profile.Profile{Name: "Turbo Test", Slug: "turbo-test", Language: "Test"} | |
| 107 | +} | |
| 108 | + | |
| 109 | +// sampleRepo makes a repository on disk shaped like turbo-editors/configs: | |
| 110 | +// golang-init/.turbo-test with three files and a nested one, on a branch | |
| 111 | +// called main, and the same on a branch called feature/x with a marker file. | |
| 112 | +func sampleRepo(t *testing.T) string { | |
| 113 | + t.Helper() | |
| 114 | + if _, err := exec.LookPath("git"); err != nil { | |
| 115 | + t.Skip("git is not installed") | |
| 116 | + } | |
| 117 | + | |
| 118 | + repo := t.TempDir() | |
| 119 | + run := func(args ...string) { | |
| 120 | + t.Helper() | |
| 121 | + cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"}, args...)...) | |
| 122 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 123 | + t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 124 | + } | |
| 125 | + } | |
| 126 | + write := func(name, contents string) { | |
| 127 | + t.Helper() | |
| 128 | + path := filepath.Join(repo, filepath.FromSlash(name)) | |
| 129 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 130 | + t.Fatal(err) | |
| 131 | + } | |
| 132 | + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { | |
| 133 | + t.Fatal(err) | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + run("init", "--quiet", "--initial-branch=main") | |
| 138 | + write("README.md", "# configs\n") | |
| 139 | + write("golang-init/README.md", "# golang-init\n") | |
| 140 | + write("golang-init/.turbo-test/settings.toml", "theme = \"turbo-dark\"\n") | |
| 141 | + write("golang-init/.turbo-test/snippets.toml", "[[snippet]]\n") | |
| 142 | + write("golang-init/.turbo-test/tools.toml", "[[tool]]\n") | |
| 143 | + write("golang-init/.turbo-test/agents/bob.yaml", "name: bob\n") | |
| 144 | + write("empty-init/README.md", "nothing for the editor here\n") | |
| 145 | + run("add", "-A") | |
| 146 | + run("commit", "--quiet", "-m", "sample") | |
| 147 | + run("checkout", "--quiet", "-b", "feature/x") | |
| 148 | + write("golang-init/.turbo-test/feature.txt", "from the branch\n") | |
| 149 | + run("add", "-A") | |
| 150 | + run("commit", "--quiet", "-m", "branch") | |
| 151 | + run("checkout", "--quiet", "main") | |
| 152 | + return repo | |
| 153 | +} | |
| 154 | + | |
| 155 | +func TestLoadCopiesTheEditorsDirectoryIntoTheProject(t *testing.T) { | |
| 156 | + repo := sampleRepo(t) | |
| 157 | + dest := t.TempDir() | |
| 158 | + | |
| 159 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest) | |
| 160 | + if err != nil { | |
| 161 | + t.Fatalf("loadFrom() error = %v", err) | |
| 162 | + } | |
| 163 | + | |
| 164 | + if result.Dir != filepath.Join(dest, ".turbo-test") || result.Remote != repo || result.Ref != "main" { | |
| 165 | + t.Errorf("Result = %+v", result) | |
| 166 | + } | |
| 167 | + want := []string{"agents/bob.yaml", "settings.toml", "snippets.toml", "tools.toml"} | |
| 168 | + if !reflect.DeepEqual(result.Files, want) { | |
| 169 | + t.Errorf("Files = %v, want %v", result.Files, want) | |
| 170 | + } | |
| 171 | + data, err := os.ReadFile(filepath.Join(dest, ".turbo-test", "settings.toml")) | |
| 172 | + if err != nil || string(data) != "theme = \"turbo-dark\"\n" { | |
| 173 | + t.Errorf("settings.toml = %q, %v", data, err) | |
| 174 | + } | |
| 175 | + if _, err := os.Stat(filepath.Join(dest, "README.md")); err == nil { | |
| 176 | + t.Error("the directory's README came along; only the editor's directory should") | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 180 | +func TestLoadTakesAURLThatAlreadyNamesTheEditorsDirectory(t *testing.T) { | |
| 181 | + repo := sampleRepo(t) | |
| 182 | + dest := t.TempDir() | |
| 183 | + | |
| 184 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init/.turbo-test"}, dest) | |
| 185 | + if err != nil { | |
| 186 | + t.Fatalf("loadFrom() error = %v", err) | |
| 187 | + } | |
| 188 | + if len(result.Files) != 4 { | |
| 189 | + t.Errorf("Files = %v, want the four files", result.Files) | |
| 190 | + } | |
| 191 | +} | |
| 192 | + | |
| 193 | +func TestLoadResolvesABranchWithASlashAgainstTheRepository(t *testing.T) { | |
| 194 | + // The URL .../tree/feature/x/golang-init reads as ref "feature", path | |
| 195 | + // "x/golang-init"; only the repository knows the branch is feature/x. | |
| 196 | + repo := sampleRepo(t) | |
| 197 | + dest := t.TempDir() | |
| 198 | + | |
| 199 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "feature", Path: "x/golang-init"}, dest) | |
| 200 | + if err != nil { | |
| 201 | + t.Fatalf("loadFrom() error = %v", err) | |
| 202 | + } | |
| 203 | + if result.Ref != "feature/x" { | |
| 204 | + t.Errorf("Ref = %q, want feature/x", result.Ref) | |
| 205 | + } | |
| 206 | + if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err != nil { | |
| 207 | + t.Error("the file only the branch has did not come: the wrong ref was cloned") | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 211 | +func TestLoadUsesTheDefaultBranchWhenTheURLNamesNone(t *testing.T) { | |
| 212 | + repo := sampleRepo(t) | |
| 213 | + dest := t.TempDir() | |
| 214 | + | |
| 215 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Path: "golang-init"}, dest) | |
| 216 | + if err != nil { | |
| 217 | + t.Fatalf("loadFrom() error = %v", err) | |
| 218 | + } | |
| 219 | + if result.Ref != "" { | |
| 220 | + t.Errorf("Ref = %q, want the default branch (empty)", result.Ref) | |
| 221 | + } | |
| 222 | + if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err == nil { | |
| 223 | + t.Error("a file from the feature branch came from the default branch") | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +func TestLoadRefusesToOverwriteAConfigurationTheProjectHas(t *testing.T) { | |
| 228 | + repo := sampleRepo(t) | |
| 229 | + dest := t.TempDir() | |
| 230 | + if err := os.MkdirAll(filepath.Join(dest, ".turbo-test"), 0o755); err != nil { | |
| 231 | + t.Fatal(err) | |
| 232 | + } | |
| 233 | + kept := filepath.Join(dest, ".turbo-test", "settings.toml") | |
| 234 | + if err := os.WriteFile(kept, []byte("mine\n"), 0o644); err != nil { | |
| 235 | + t.Fatal(err) | |
| 236 | + } | |
| 237 | + | |
| 238 | + _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest) | |
| 239 | + | |
| 240 | + if !errors.Is(err, ErrExists) { | |
| 241 | + t.Fatalf("error = %v, want ErrExists", err) | |
| 242 | + } | |
| 243 | + if data, _ := os.ReadFile(kept); string(data) != "mine\n" { | |
| 244 | + t.Errorf("the project's own settings were overwritten: %q", data) | |
| 245 | + } | |
| 246 | +} | |
| 247 | + | |
| 248 | +func TestLoadSaysWhenTheDirectoryHasNoConfiguration(t *testing.T) { | |
| 249 | + repo := sampleRepo(t) | |
| 250 | + | |
| 251 | + _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "empty-init"}, t.TempDir()) | |
| 252 | + | |
| 253 | + if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), ".turbo-test at empty-init") { | |
| 254 | + t.Errorf("error = %v, want ErrNotFound naming the directory and the path", err) | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +func TestLoadReportsABranchThatDoesNotExistInGitsWords(t *testing.T) { | |
| 259 | + repo := sampleRepo(t) | |
| 260 | + | |
| 261 | + _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "nowhere", Path: "golang-init"}, t.TempDir()) | |
| 262 | + | |
| 263 | + if err == nil || !strings.Contains(err.Error(), "nowhere") { | |
| 264 | + t.Errorf("error = %v, want git's complaint about the branch", err) | |
| 265 | + } | |
| 266 | +} | |
| 267 | + | |
| 268 | +func TestLoadFallsThroughToTheRemoteThatAnswers(t *testing.T) { | |
| 269 | + // rickub.com does not serve git; git.rickub.com does. The first candidate | |
| 270 | + // failing must cost a round trip, not the load. | |
| 271 | + repo := sampleRepo(t) | |
| 272 | + dest := t.TempDir() | |
| 273 | + | |
| 274 | + result, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "nowhere.git"), repo}, Source{Ref: "main", Path: "golang-init"}, dest) | |
| 275 | + if err != nil { | |
| 276 | + t.Fatalf("loadFrom() error = %v", err) | |
| 277 | + } | |
| 278 | + if result.Remote != repo { | |
| 279 | + t.Errorf("Remote = %q, want the one that answered", result.Remote) | |
| 280 | + } | |
| 281 | +} | |
| 282 | + | |
| 283 | +func TestLoadNamesEveryRemoteWhenNoneAnswers(t *testing.T) { | |
| 284 | + sampleRepo(t) // for the git skip | |
| 285 | + | |
| 286 | + _, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "a.git"), filepath.Join(t.TempDir(), "b.git")}, Source{}, t.TempDir()) | |
| 287 | + | |
| 288 | + if err == nil || !strings.Contains(err.Error(), "a.git") || !strings.Contains(err.Error(), "b.git") { | |
| 289 | + t.Errorf("error = %v, want both remotes named", err) | |
| 290 | + } | |
| 291 | +} | |
| 292 | + | |
| 293 | +func TestLoadFromRickubOverTheNetwork(t *testing.T) { | |
| 294 | + // The real thing, against the repository the feature was asked for. Off by | |
| 295 | + // default: the suite must pass on a machine with no network. | |
| 296 | + if os.Getenv("TURBO_CORE_NETWORK") == "" { | |
| 297 | + t.Skip("TURBO_CORE_NETWORK is not set") | |
| 298 | + } | |
| 299 | + dest := t.TempDir() | |
| 300 | + p := profile.Profile{Name: "Turbo Go", Slug: "turbo-go", Language: "Go"} | |
| 301 | + | |
| 302 | + result, err := Load(context.Background(), p, "https://rickub.com/turbo-editors/configs/tree/main/golang-init", dest) | |
| 303 | + if err != nil { | |
| 304 | + t.Fatalf("Load() error = %v", err) | |
| 305 | + } | |
| 306 | + if result.Remote != "https://git.rickub.com/turbo-editors/configs.git" { | |
| 307 | + t.Errorf("Remote = %q", result.Remote) | |
| 308 | + } | |
| 309 | + for _, name := range []string{"settings.toml", "snippets.toml", "tools.toml"} { | |
| 310 | + if _, err := os.Stat(filepath.Join(dest, ".turbo-go", name)); err != nil { | |
| 311 | + t.Errorf("%s did not arrive: %v", name, err) | |
| 312 | + } | |
| 313 | + } | |
| 314 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,314 @@ | |||
| 1 | +package configrepo | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "os" | ||
| 7 | + "os/exec" | ||
| 8 | + "path/filepath" | ||
| 9 | + "reflect" | ||
| 10 | + "strings" | ||
| 11 | + "testing" | ||
| 12 | + | ||
| 13 | + "rickub.com/turbo-editors/turbo-core/profile" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +func TestParseReadsTheURLsTheForgesShow(t *testing.T) { | ||
| 17 | + cases := []struct { | ||
| 18 | + url string | ||
| 19 | + want Source | ||
| 20 | + }{ | ||
| 21 | + {"https://rickub.com/turbo-editors/configs/tree/main/golang-init", | ||
| 22 | + Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init"}}, | ||
| 23 | + {"https://github.com/acme/configs/tree/main/go/service", | ||
| 24 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go/service"}}, | ||
| 25 | + {"https://gitlab.com/acme/platform/configs/-/tree/main/go-service", | ||
| 26 | + Source{Host: "gitlab.com", Owner: "acme/platform", Repo: "configs", Ref: "main", Path: "go-service"}}, | ||
| 27 | + {"https://codeberg.org/acme/configs/src/branch/main/go-service", | ||
| 28 | + Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}}, | ||
| 29 | + {"https://codeberg.org/acme/configs/src/tag/v1.0.0/go-service", | ||
| 30 | + Source{Host: "codeberg.org", Owner: "acme", Repo: "configs", Ref: "v1.0.0", Path: "go-service"}}, | ||
| 31 | + {"https://github.com/acme/configs", | ||
| 32 | + Source{Host: "github.com", Owner: "acme", Repo: "configs"}}, | ||
| 33 | + {"https://github.com/acme/configs.git", | ||
| 34 | + Source{Host: "github.com", Owner: "acme", Repo: "configs"}}, | ||
| 35 | + {"https://github.com/acme/configs/tree/main", | ||
| 36 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main"}}, | ||
| 37 | + {"https://github.com/acme/configs/tree/main/go-service/", | ||
| 38 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service"}}, | ||
| 39 | + {"https://github.com/acme/configs/blob/main/go-service/.turbo-go/settings.toml", | ||
| 40 | + Source{Host: "github.com", Owner: "acme", Repo: "configs", Ref: "main", Path: "go-service/.turbo-go/settings.toml"}}, | ||
| 41 | + {" https://rickub.com/turbo-editors/configs/tree/main/golang-init-with-agents\n", | ||
| 42 | + Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs", Ref: "main", Path: "golang-init-with-agents"}}, | ||
| 43 | + } | ||
| 44 | + for _, c := range cases { | ||
| 45 | + got, err := Parse(c.url) | ||
| 46 | + if err != nil { | ||
| 47 | + t.Errorf("Parse(%q) error = %v", c.url, err) | ||
| 48 | + continue | ||
| 49 | + } | ||
| 50 | + if got != c.want { | ||
| 51 | + t.Errorf("Parse(%q) = %+v, want %+v", c.url, got, c.want) | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +func TestParseRefusesWhatIsNotARepository(t *testing.T) { | ||
| 57 | + for _, bad := range []string{"", "configs", "https://rickub.com", "https://rickub.com/turbo-editors", "ftp://x/y/z", "not a url at all ://"} { | ||
| 58 | + if _, err := Parse(bad); err == nil { | ||
| 59 | + t.Errorf("Parse(%q) accepted something that names no repository", bad) | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +func TestCloneURLsTryTheHostThenItsGitSubdomain(t *testing.T) { | ||
| 65 | + // rickub's pages are on rickub.com and its repositories on git.rickub.com. | ||
| 66 | + src := Source{Host: "rickub.com", Owner: "turbo-editors", Repo: "configs"} | ||
| 67 | + | ||
| 68 | + want := []string{"https://rickub.com/turbo-editors/configs.git", "https://git.rickub.com/turbo-editors/configs.git"} | ||
| 69 | + if got := src.CloneURLs(); !reflect.DeepEqual(got, want) { | ||
| 70 | + t.Errorf("CloneURLs() = %v, want %v", got, want) | ||
| 71 | + } | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +func TestResolveRefSettlesABranchWithASlashInIt(t *testing.T) { | ||
| 75 | + refs := []string{"main", "feature/x", "feature/x/deeper", "v1.0.0"} | ||
| 76 | + cases := []struct { | ||
| 77 | + ref, path, wantRef, wantPath string | ||
| 78 | + }{ | ||
| 79 | + {"main", "golang-init", "main", "golang-init"}, | ||
| 80 | + {"feature", "x/golang-init", "feature/x", "golang-init"}, | ||
| 81 | + {"feature", "x/deeper/golang-init", "feature/x/deeper", "golang-init"}, | ||
| 82 | + {"feature", "x", "feature/x", ""}, | ||
| 83 | + {"v1.0.0", "", "v1.0.0", ""}, | ||
| 84 | + {"nowhere", "golang-init", "nowhere", "golang-init"}, // left alone; the clone will say | ||
| 85 | + {"", "", "", ""}, // the default branch | ||
| 86 | + } | ||
| 87 | + for _, c := range cases { | ||
| 88 | + ref, path := resolveRef(refs, c.ref, c.path) | ||
| 89 | + if ref != c.wantRef || path != c.wantPath { | ||
| 90 | + t.Errorf("resolveRef(%q, %q) = %q, %q; want %q, %q", c.ref, c.path, ref, path, c.wantRef, c.wantPath) | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +func TestParseRefsReadsBranchesAndTagsAndDropsPeeledTags(t *testing.T) { | ||
| 96 | + output := "abc\tHEAD\nabc\trefs/heads/main\ndef\trefs/heads/feature/x\n123\trefs/tags/v1.0.0\n456\trefs/tags/v1.0.0^{}\n" | ||
| 97 | + | ||
| 98 | + want := []string{"main", "feature/x", "v1.0.0"} | ||
| 99 | + if got := parseRefs(output); !reflect.DeepEqual(got, want) { | ||
| 100 | + t.Errorf("parseRefs() = %v, want %v", got, want) | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// testProfile is an editor whose project directory is .turbo-test. | ||
| 105 | +func testProfile() profile.Profile { | ||
| 106 | + return profile.Profile{Name: "Turbo Test", Slug: "turbo-test", Language: "Test"} | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// sampleRepo makes a repository on disk shaped like turbo-editors/configs: | ||
| 110 | +// golang-init/.turbo-test with three files and a nested one, on a branch | ||
| 111 | +// called main, and the same on a branch called feature/x with a marker file. | ||
| 112 | +func sampleRepo(t *testing.T) string { | ||
| 113 | + t.Helper() | ||
| 114 | + if _, err := exec.LookPath("git"); err != nil { | ||
| 115 | + t.Skip("git is not installed") | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + repo := t.TempDir() | ||
| 119 | + run := func(args ...string) { | ||
| 120 | + t.Helper() | ||
| 121 | + cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"}, args...)...) | ||
| 122 | + if out, err := cmd.CombinedOutput(); err != nil { | ||
| 123 | + t.Fatalf("git %v: %v\n%s", args, err, out) | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + write := func(name, contents string) { | ||
| 127 | + t.Helper() | ||
| 128 | + path := filepath.Join(repo, filepath.FromSlash(name)) | ||
| 129 | + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | ||
| 130 | + t.Fatal(err) | ||
| 131 | + } | ||
| 132 | + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { | ||
| 133 | + t.Fatal(err) | ||
| 134 | + } | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + run("init", "--quiet", "--initial-branch=main") | ||
| 138 | + write("README.md", "# configs\n") | ||
| 139 | + write("golang-init/README.md", "# golang-init\n") | ||
| 140 | + write("golang-init/.turbo-test/settings.toml", "theme = \"turbo-dark\"\n") | ||
| 141 | + write("golang-init/.turbo-test/snippets.toml", "[[snippet]]\n") | ||
| 142 | + write("golang-init/.turbo-test/tools.toml", "[[tool]]\n") | ||
| 143 | + write("golang-init/.turbo-test/agents/bob.yaml", "name: bob\n") | ||
| 144 | + write("empty-init/README.md", "nothing for the editor here\n") | ||
| 145 | + run("add", "-A") | ||
| 146 | + run("commit", "--quiet", "-m", "sample") | ||
| 147 | + run("checkout", "--quiet", "-b", "feature/x") | ||
| 148 | + write("golang-init/.turbo-test/feature.txt", "from the branch\n") | ||
| 149 | + run("add", "-A") | ||
| 150 | + run("commit", "--quiet", "-m", "branch") | ||
| 151 | + run("checkout", "--quiet", "main") | ||
| 152 | + return repo | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +func TestLoadCopiesTheEditorsDirectoryIntoTheProject(t *testing.T) { | ||
| 156 | + repo := sampleRepo(t) | ||
| 157 | + dest := t.TempDir() | ||
| 158 | + | ||
| 159 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest) | ||
| 160 | + if err != nil { | ||
| 161 | + t.Fatalf("loadFrom() error = %v", err) | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + if result.Dir != filepath.Join(dest, ".turbo-test") || result.Remote != repo || result.Ref != "main" { | ||
| 165 | + t.Errorf("Result = %+v", result) | ||
| 166 | + } | ||
| 167 | + want := []string{"agents/bob.yaml", "settings.toml", "snippets.toml", "tools.toml"} | ||
| 168 | + if !reflect.DeepEqual(result.Files, want) { | ||
| 169 | + t.Errorf("Files = %v, want %v", result.Files, want) | ||
| 170 | + } | ||
| 171 | + data, err := os.ReadFile(filepath.Join(dest, ".turbo-test", "settings.toml")) | ||
| 172 | + if err != nil || string(data) != "theme = \"turbo-dark\"\n" { | ||
| 173 | + t.Errorf("settings.toml = %q, %v", data, err) | ||
| 174 | + } | ||
| 175 | + if _, err := os.Stat(filepath.Join(dest, "README.md")); err == nil { | ||
| 176 | + t.Error("the directory's README came along; only the editor's directory should") | ||
| 177 | + } | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +func TestLoadTakesAURLThatAlreadyNamesTheEditorsDirectory(t *testing.T) { | ||
| 181 | + repo := sampleRepo(t) | ||
| 182 | + dest := t.TempDir() | ||
| 183 | + | ||
| 184 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init/.turbo-test"}, dest) | ||
| 185 | + if err != nil { | ||
| 186 | + t.Fatalf("loadFrom() error = %v", err) | ||
| 187 | + } | ||
| 188 | + if len(result.Files) != 4 { | ||
| 189 | + t.Errorf("Files = %v, want the four files", result.Files) | ||
| 190 | + } | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +func TestLoadResolvesABranchWithASlashAgainstTheRepository(t *testing.T) { | ||
| 194 | + // The URL .../tree/feature/x/golang-init reads as ref "feature", path | ||
| 195 | + // "x/golang-init"; only the repository knows the branch is feature/x. | ||
| 196 | + repo := sampleRepo(t) | ||
| 197 | + dest := t.TempDir() | ||
| 198 | + | ||
| 199 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "feature", Path: "x/golang-init"}, dest) | ||
| 200 | + if err != nil { | ||
| 201 | + t.Fatalf("loadFrom() error = %v", err) | ||
| 202 | + } | ||
| 203 | + if result.Ref != "feature/x" { | ||
| 204 | + t.Errorf("Ref = %q, want feature/x", result.Ref) | ||
| 205 | + } | ||
| 206 | + if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err != nil { | ||
| 207 | + t.Error("the file only the branch has did not come: the wrong ref was cloned") | ||
| 208 | + } | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +func TestLoadUsesTheDefaultBranchWhenTheURLNamesNone(t *testing.T) { | ||
| 212 | + repo := sampleRepo(t) | ||
| 213 | + dest := t.TempDir() | ||
| 214 | + | ||
| 215 | + result, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Path: "golang-init"}, dest) | ||
| 216 | + if err != nil { | ||
| 217 | + t.Fatalf("loadFrom() error = %v", err) | ||
| 218 | + } | ||
| 219 | + if result.Ref != "" { | ||
| 220 | + t.Errorf("Ref = %q, want the default branch (empty)", result.Ref) | ||
| 221 | + } | ||
| 222 | + if _, err := os.Stat(filepath.Join(dest, ".turbo-test", "feature.txt")); err == nil { | ||
| 223 | + t.Error("a file from the feature branch came from the default branch") | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +func TestLoadRefusesToOverwriteAConfigurationTheProjectHas(t *testing.T) { | ||
| 228 | + repo := sampleRepo(t) | ||
| 229 | + dest := t.TempDir() | ||
| 230 | + if err := os.MkdirAll(filepath.Join(dest, ".turbo-test"), 0o755); err != nil { | ||
| 231 | + t.Fatal(err) | ||
| 232 | + } | ||
| 233 | + kept := filepath.Join(dest, ".turbo-test", "settings.toml") | ||
| 234 | + if err := os.WriteFile(kept, []byte("mine\n"), 0o644); err != nil { | ||
| 235 | + t.Fatal(err) | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "golang-init"}, dest) | ||
| 239 | + | ||
| 240 | + if !errors.Is(err, ErrExists) { | ||
| 241 | + t.Fatalf("error = %v, want ErrExists", err) | ||
| 242 | + } | ||
| 243 | + if data, _ := os.ReadFile(kept); string(data) != "mine\n" { | ||
| 244 | + t.Errorf("the project's own settings were overwritten: %q", data) | ||
| 245 | + } | ||
| 246 | +} | ||
| 247 | + | ||
| 248 | +func TestLoadSaysWhenTheDirectoryHasNoConfiguration(t *testing.T) { | ||
| 249 | + repo := sampleRepo(t) | ||
| 250 | + | ||
| 251 | + _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "main", Path: "empty-init"}, t.TempDir()) | ||
| 252 | + | ||
| 253 | + if !errors.Is(err, ErrNotFound) || !strings.Contains(err.Error(), ".turbo-test at empty-init") { | ||
| 254 | + t.Errorf("error = %v, want ErrNotFound naming the directory and the path", err) | ||
| 255 | + } | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +func TestLoadReportsABranchThatDoesNotExistInGitsWords(t *testing.T) { | ||
| 259 | + repo := sampleRepo(t) | ||
| 260 | + | ||
| 261 | + _, err := loadFrom(context.Background(), testProfile(), []string{repo}, Source{Ref: "nowhere", Path: "golang-init"}, t.TempDir()) | ||
| 262 | + | ||
| 263 | + if err == nil || !strings.Contains(err.Error(), "nowhere") { | ||
| 264 | + t.Errorf("error = %v, want git's complaint about the branch", err) | ||
| 265 | + } | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +func TestLoadFallsThroughToTheRemoteThatAnswers(t *testing.T) { | ||
| 269 | + // rickub.com does not serve git; git.rickub.com does. The first candidate | ||
| 270 | + // failing must cost a round trip, not the load. | ||
| 271 | + repo := sampleRepo(t) | ||
| 272 | + dest := t.TempDir() | ||
| 273 | + | ||
| 274 | + result, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "nowhere.git"), repo}, Source{Ref: "main", Path: "golang-init"}, dest) | ||
| 275 | + if err != nil { | ||
| 276 | + t.Fatalf("loadFrom() error = %v", err) | ||
| 277 | + } | ||
| 278 | + if result.Remote != repo { | ||
| 279 | + t.Errorf("Remote = %q, want the one that answered", result.Remote) | ||
| 280 | + } | ||
| 281 | +} | ||
| 282 | + | ||
| 283 | +func TestLoadNamesEveryRemoteWhenNoneAnswers(t *testing.T) { | ||
| 284 | + sampleRepo(t) // for the git skip | ||
| 285 | + | ||
| 286 | + _, err := loadFrom(context.Background(), testProfile(), []string{filepath.Join(t.TempDir(), "a.git"), filepath.Join(t.TempDir(), "b.git")}, Source{}, t.TempDir()) | ||
| 287 | + | ||
| 288 | + if err == nil || !strings.Contains(err.Error(), "a.git") || !strings.Contains(err.Error(), "b.git") { | ||
| 289 | + t.Errorf("error = %v, want both remotes named", err) | ||
| 290 | + } | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +func TestLoadFromRickubOverTheNetwork(t *testing.T) { | ||
| 294 | + // The real thing, against the repository the feature was asked for. Off by | ||
| 295 | + // default: the suite must pass on a machine with no network. | ||
| 296 | + if os.Getenv("TURBO_CORE_NETWORK") == "" { | ||
| 297 | + t.Skip("TURBO_CORE_NETWORK is not set") | ||
| 298 | + } | ||
| 299 | + dest := t.TempDir() | ||
| 300 | + p := profile.Profile{Name: "Turbo Go", Slug: "turbo-go", Language: "Go"} | ||
| 301 | + | ||
| 302 | + result, err := Load(context.Background(), p, "https://rickub.com/turbo-editors/configs/tree/main/golang-init", dest) | ||
| 303 | + if err != nil { | ||
| 304 | + t.Fatalf("Load() error = %v", err) | ||
| 305 | + } | ||
| 306 | + if result.Remote != "https://git.rickub.com/turbo-editors/configs.git" { | ||
| 307 | + t.Errorf("Remote = %q", result.Remote) | ||
| 308 | + } | ||
| 309 | + for _, name := range []string{"settings.toml", "snippets.toml", "tools.toml"} { | ||
| 310 | + if _, err := os.Stat(filepath.Join(dest, ".turbo-go", name)); err != nil { | ||
| 311 | + t.Errorf("%s did not arrive: %v", name, err) | ||
| 312 | + } | ||
| 313 | + } | ||
| 314 | +} | ||
added
configrepo/copy.go +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +// Copying the fetched directory into the project. | |
| 2 | + | |
| 3 | +package configrepo | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "fmt" | |
| 7 | + "io/fs" | |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 10 | + | |
| 11 | + "rickub.com/turbo-editors/turbo-core/projectfile" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// copyTree copies every regular file under from into to, keeping the | |
| 15 | +// layout, and returns their names relative to to. | |
| 16 | +// | |
| 17 | +// Each file goes through projectfile.Write, so it is created the way the | |
| 18 | +// editor creates a project file itself — same mode, same atomic rename, the | |
| 19 | +// directory made on the way. Anything that is not a regular file — a | |
| 20 | +// symbolic link, a socket — is left behind: a configuration is files. | |
| 21 | +func copyTree(from, to string) ([]string, error) { | |
| 22 | + var files []string | |
| 23 | + err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error { | |
| 24 | + if err != nil || !entry.Type().IsRegular() { | |
| 25 | + return err | |
| 26 | + } | |
| 27 | + relative, err := copyFile(from, to, path) | |
| 28 | + if err == nil { | |
| 29 | + files = append(files, relative) | |
| 30 | + } | |
| 31 | + return err | |
| 32 | + }) | |
| 33 | + if err != nil { | |
| 34 | + return nil, fmt.Errorf("configrepo: copying into %s: %w", to, err) | |
| 35 | + } | |
| 36 | + return files, nil | |
| 37 | +} | |
| 38 | + | |
| 39 | +// copyFile copies one file from under from to the same place under to, and | |
| 40 | +// returns its name relative to both. | |
| 41 | +func copyFile(from, to, path string) (string, error) { | |
| 42 | + relative, err := filepath.Rel(from, path) | |
| 43 | + if err != nil { | |
| 44 | + return "", err | |
| 45 | + } | |
| 46 | + data, err := os.ReadFile(path) | |
| 47 | + if err != nil { | |
| 48 | + return "", err | |
| 49 | + } | |
| 50 | + if err := projectfile.Write(filepath.Join(to, relative), data); err != nil { | |
| 51 | + return "", err | |
| 52 | + } | |
| 53 | + return filepath.ToSlash(relative), nil | |
| 54 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +// Copying the fetched directory into the project. | ||
| 2 | + | ||
| 3 | +package configrepo | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "fmt" | ||
| 7 | + "io/fs" | ||
| 8 | + "os" | ||
| 9 | + "path/filepath" | ||
| 10 | + | ||
| 11 | + "rickub.com/turbo-editors/turbo-core/projectfile" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// copyTree copies every regular file under from into to, keeping the | ||
| 15 | +// layout, and returns their names relative to to. | ||
| 16 | +// | ||
| 17 | +// Each file goes through projectfile.Write, so it is created the way the | ||
| 18 | +// editor creates a project file itself — same mode, same atomic rename, the | ||
| 19 | +// directory made on the way. Anything that is not a regular file — a | ||
| 20 | +// symbolic link, a socket — is left behind: a configuration is files. | ||
| 21 | +func copyTree(from, to string) ([]string, error) { | ||
| 22 | + var files []string | ||
| 23 | + err := filepath.WalkDir(from, func(path string, entry fs.DirEntry, err error) error { | ||
| 24 | + if err != nil || !entry.Type().IsRegular() { | ||
| 25 | + return err | ||
| 26 | + } | ||
| 27 | + relative, err := copyFile(from, to, path) | ||
| 28 | + if err == nil { | ||
| 29 | + files = append(files, relative) | ||
| 30 | + } | ||
| 31 | + return err | ||
| 32 | + }) | ||
| 33 | + if err != nil { | ||
| 34 | + return nil, fmt.Errorf("configrepo: copying into %s: %w", to, err) | ||
| 35 | + } | ||
| 36 | + return files, nil | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// copyFile copies one file from under from to the same place under to, and | ||
| 40 | +// returns its name relative to both. | ||
| 41 | +func copyFile(from, to, path string) (string, error) { | ||
| 42 | + relative, err := filepath.Rel(from, path) | ||
| 43 | + if err != nil { | ||
| 44 | + return "", err | ||
| 45 | + } | ||
| 46 | + data, err := os.ReadFile(path) | ||
| 47 | + if err != nil { | ||
| 48 | + return "", err | ||
| 49 | + } | ||
| 50 | + if err := projectfile.Write(filepath.Join(to, relative), data); err != nil { | ||
| 51 | + return "", err | ||
| 52 | + } | ||
| 53 | + return filepath.ToSlash(relative), nil | ||
| 54 | +} | ||
added
configrepo/git.go +139 -0 | new file mode 100644 | ||
| @@ -0,0 +1,139 @@ | ||
| 1 | +// Talking to git: which remote answers, what it calls its branches, and a | |
| 2 | +// shallow clone of one of them. | |
| 3 | + | |
| 4 | +package configrepo | |
| 5 | + | |
| 6 | +import ( | |
| 7 | + "context" | |
| 8 | + "errors" | |
| 9 | + "fmt" | |
| 10 | + "os/exec" | |
| 11 | + "strings" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// git runs the git that was found on PATH. | |
| 15 | +type git struct { | |
| 16 | + path string | |
| 17 | +} | |
| 18 | + | |
| 19 | +// fetch finds the repository among the remotes, settles which ref the URL | |
| 20 | +// meant, and clones that ref into dir. It returns the remote that answered, | |
| 21 | +// the ref, and the path inside the repository once the ref has taken its | |
| 22 | +// share of it. | |
| 23 | +func (g git) fetch(ctx context.Context, remotes []string, src Source, dir string) (remote, ref, path string, err error) { | |
| 24 | + remote, refs, err := g.firstAnswering(ctx, remotes) | |
| 25 | + if err != nil { | |
| 26 | + return "", "", "", err | |
| 27 | + } | |
| 28 | + ref, path = resolveRef(refs, src.Ref, src.Path) | |
| 29 | + if err := g.clone(ctx, remote, ref, dir); err != nil { | |
| 30 | + return "", "", "", err | |
| 31 | + } | |
| 32 | + return remote, ref, path, nil | |
| 33 | +} | |
| 34 | + | |
| 35 | +// firstAnswering asks each remote for its refs and returns the first that | |
| 36 | +// answers, with what it said. | |
| 37 | +// | |
| 38 | +// The listing does two jobs: it tells the candidates apart — a forge whose | |
| 39 | +// pages and repositories live on different hosts fails fast here, not after | |
| 40 | +// a clone — and it is what a ref with a slash in it is resolved against. | |
| 41 | +func (g git) firstAnswering(ctx context.Context, remotes []string) (string, []string, error) { | |
| 42 | + var failures []error | |
| 43 | + for _, remote := range remotes { | |
| 44 | + refs, err := g.lsRemote(ctx, remote) | |
| 45 | + if err == nil { | |
| 46 | + return remote, refs, nil | |
| 47 | + } | |
| 48 | + failures = append(failures, err) | |
| 49 | + } | |
| 50 | + return "", nil, fmt.Errorf("configrepo: no repository answered: %w", errors.Join(failures...)) | |
| 51 | +} | |
| 52 | + | |
| 53 | +// lsRemote returns the names of a remote's branches and tags. | |
| 54 | +func (g git) lsRemote(ctx context.Context, remote string) ([]string, error) { | |
| 55 | + out, err := g.run(ctx, "ls-remote", "--heads", "--tags", "--", remote) | |
| 56 | + if err != nil { | |
| 57 | + return nil, err | |
| 58 | + } | |
| 59 | + return parseRefs(out), nil | |
| 60 | +} | |
| 61 | + | |
| 62 | +// parseRefs reads ls-remote's output — a hash, a tab, a ref — into names. | |
| 63 | +// Peeled tags ("v1^{}") name the same tag twice and are dropped. | |
| 64 | +func parseRefs(output string) []string { | |
| 65 | + var names []string | |
| 66 | + for _, line := range strings.Split(output, "\n") { | |
| 67 | + _, ref, ok := strings.Cut(line, "\t") | |
| 68 | + if !ok || strings.HasSuffix(ref, "^{}") { | |
| 69 | + continue | |
| 70 | + } | |
| 71 | + for _, prefix := range []string{"refs/heads/", "refs/tags/"} { | |
| 72 | + if name, found := strings.CutPrefix(ref, prefix); found { | |
| 73 | + names = append(names, name) | |
| 74 | + } | |
| 75 | + } | |
| 76 | + } | |
| 77 | + return names | |
| 78 | +} | |
| 79 | + | |
| 80 | +// resolveRef settles where the branch ends and the path begins. | |
| 81 | +// | |
| 82 | +// The URL gave a first segment and a rest; a branch called "feature/x" makes | |
| 83 | +// the first segment "feature", which names nothing. So the two are joined | |
| 84 | +// back together and the **longest** branch or tag that begins them wins, | |
| 85 | +// with the remainder as the path. Nothing matching leaves both as they were, | |
| 86 | +// and the clone then says, in git's own words, that there is no such branch. | |
| 87 | +func resolveRef(refs []string, ref, path string) (string, string) { | |
| 88 | + if ref == "" { | |
| 89 | + return ref, path | |
| 90 | + } | |
| 91 | + full := ref | |
| 92 | + if path != "" { | |
| 93 | + full += "/" + path | |
| 94 | + } | |
| 95 | + | |
| 96 | + best := "" | |
| 97 | + for _, name := range refs { | |
| 98 | + if name != full && !strings.HasPrefix(full, name+"/") { | |
| 99 | + continue | |
| 100 | + } | |
| 101 | + if len(name) > len(best) { | |
| 102 | + best = name | |
| 103 | + } | |
| 104 | + } | |
| 105 | + if best == "" { | |
| 106 | + return ref, path | |
| 107 | + } | |
| 108 | + return best, strings.TrimPrefix(strings.TrimPrefix(full, best), "/") | |
| 109 | +} | |
| 110 | + | |
| 111 | +// clone makes a shallow checkout of one ref — or of the default branch when | |
| 112 | +// ref is empty — into dir, which must exist and be empty. | |
| 113 | +func (g git) clone(ctx context.Context, remote, ref, dir string) error { | |
| 114 | + args := []string{"clone", "--quiet", "--depth", "1"} | |
| 115 | + if ref != "" { | |
| 116 | + args = append(args, "--branch", ref) | |
| 117 | + } | |
| 118 | + args = append(args, "--", remote, dir) | |
| 119 | + _, err := g.run(ctx, args...) | |
| 120 | + return err | |
| 121 | +} | |
| 122 | + | |
| 123 | +// run executes git and returns what it printed, or what it complained about. | |
| 124 | +func (g git) run(ctx context.Context, args ...string) (string, error) { | |
| 125 | + cmd := exec.CommandContext(ctx, g.path, args...) | |
| 126 | + // Never a password prompt: this runs from a command line that asked for a | |
| 127 | + // URL, and a repository that needs one is reported, not waited on. | |
| 128 | + cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0") | |
| 129 | + | |
| 130 | + out, err := cmd.Output() | |
| 131 | + if err != nil { | |
| 132 | + var exit *exec.ExitError | |
| 133 | + if errors.As(err, &exit) && len(exit.Stderr) > 0 { | |
| 134 | + return "", fmt.Errorf("configrepo: git %s: %s", args[0], strings.TrimSpace(string(exit.Stderr))) | |
| 135 | + } | |
| 136 | + return "", fmt.Errorf("configrepo: git %s: %w", args[0], err) | |
| 137 | + } | |
| 138 | + return string(out), nil | |
| 139 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,139 @@ | |||
| 1 | +// Talking to git: which remote answers, what it calls its branches, and a | ||
| 2 | +// shallow clone of one of them. | ||
| 3 | + | ||
| 4 | +package configrepo | ||
| 5 | + | ||
| 6 | +import ( | ||
| 7 | + "context" | ||
| 8 | + "errors" | ||
| 9 | + "fmt" | ||
| 10 | + "os/exec" | ||
| 11 | + "strings" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// git runs the git that was found on PATH. | ||
| 15 | +type git struct { | ||
| 16 | + path string | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// fetch finds the repository among the remotes, settles which ref the URL | ||
| 20 | +// meant, and clones that ref into dir. It returns the remote that answered, | ||
| 21 | +// the ref, and the path inside the repository once the ref has taken its | ||
| 22 | +// share of it. | ||
| 23 | +func (g git) fetch(ctx context.Context, remotes []string, src Source, dir string) (remote, ref, path string, err error) { | ||
| 24 | + remote, refs, err := g.firstAnswering(ctx, remotes) | ||
| 25 | + if err != nil { | ||
| 26 | + return "", "", "", err | ||
| 27 | + } | ||
| 28 | + ref, path = resolveRef(refs, src.Ref, src.Path) | ||
| 29 | + if err := g.clone(ctx, remote, ref, dir); err != nil { | ||
| 30 | + return "", "", "", err | ||
| 31 | + } | ||
| 32 | + return remote, ref, path, nil | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +// firstAnswering asks each remote for its refs and returns the first that | ||
| 36 | +// answers, with what it said. | ||
| 37 | +// | ||
| 38 | +// The listing does two jobs: it tells the candidates apart — a forge whose | ||
| 39 | +// pages and repositories live on different hosts fails fast here, not after | ||
| 40 | +// a clone — and it is what a ref with a slash in it is resolved against. | ||
| 41 | +func (g git) firstAnswering(ctx context.Context, remotes []string) (string, []string, error) { | ||
| 42 | + var failures []error | ||
| 43 | + for _, remote := range remotes { | ||
| 44 | + refs, err := g.lsRemote(ctx, remote) | ||
| 45 | + if err == nil { | ||
| 46 | + return remote, refs, nil | ||
| 47 | + } | ||
| 48 | + failures = append(failures, err) | ||
| 49 | + } | ||
| 50 | + return "", nil, fmt.Errorf("configrepo: no repository answered: %w", errors.Join(failures...)) | ||
| 51 | +} | ||
| 52 | + | ||
| 53 | +// lsRemote returns the names of a remote's branches and tags. | ||
| 54 | +func (g git) lsRemote(ctx context.Context, remote string) ([]string, error) { | ||
| 55 | + out, err := g.run(ctx, "ls-remote", "--heads", "--tags", "--", remote) | ||
| 56 | + if err != nil { | ||
| 57 | + return nil, err | ||
| 58 | + } | ||
| 59 | + return parseRefs(out), nil | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// parseRefs reads ls-remote's output — a hash, a tab, a ref — into names. | ||
| 63 | +// Peeled tags ("v1^{}") name the same tag twice and are dropped. | ||
| 64 | +func parseRefs(output string) []string { | ||
| 65 | + var names []string | ||
| 66 | + for _, line := range strings.Split(output, "\n") { | ||
| 67 | + _, ref, ok := strings.Cut(line, "\t") | ||
| 68 | + if !ok || strings.HasSuffix(ref, "^{}") { | ||
| 69 | + continue | ||
| 70 | + } | ||
| 71 | + for _, prefix := range []string{"refs/heads/", "refs/tags/"} { | ||
| 72 | + if name, found := strings.CutPrefix(ref, prefix); found { | ||
| 73 | + names = append(names, name) | ||
| 74 | + } | ||
| 75 | + } | ||
| 76 | + } | ||
| 77 | + return names | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// resolveRef settles where the branch ends and the path begins. | ||
| 81 | +// | ||
| 82 | +// The URL gave a first segment and a rest; a branch called "feature/x" makes | ||
| 83 | +// the first segment "feature", which names nothing. So the two are joined | ||
| 84 | +// back together and the **longest** branch or tag that begins them wins, | ||
| 85 | +// with the remainder as the path. Nothing matching leaves both as they were, | ||
| 86 | +// and the clone then says, in git's own words, that there is no such branch. | ||
| 87 | +func resolveRef(refs []string, ref, path string) (string, string) { | ||
| 88 | + if ref == "" { | ||
| 89 | + return ref, path | ||
| 90 | + } | ||
| 91 | + full := ref | ||
| 92 | + if path != "" { | ||
| 93 | + full += "/" + path | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + best := "" | ||
| 97 | + for _, name := range refs { | ||
| 98 | + if name != full && !strings.HasPrefix(full, name+"/") { | ||
| 99 | + continue | ||
| 100 | + } | ||
| 101 | + if len(name) > len(best) { | ||
| 102 | + best = name | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + if best == "" { | ||
| 106 | + return ref, path | ||
| 107 | + } | ||
| 108 | + return best, strings.TrimPrefix(strings.TrimPrefix(full, best), "/") | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +// clone makes a shallow checkout of one ref — or of the default branch when | ||
| 112 | +// ref is empty — into dir, which must exist and be empty. | ||
| 113 | +func (g git) clone(ctx context.Context, remote, ref, dir string) error { | ||
| 114 | + args := []string{"clone", "--quiet", "--depth", "1"} | ||
| 115 | + if ref != "" { | ||
| 116 | + args = append(args, "--branch", ref) | ||
| 117 | + } | ||
| 118 | + args = append(args, "--", remote, dir) | ||
| 119 | + _, err := g.run(ctx, args...) | ||
| 120 | + return err | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +// run executes git and returns what it printed, or what it complained about. | ||
| 124 | +func (g git) run(ctx context.Context, args ...string) (string, error) { | ||
| 125 | + cmd := exec.CommandContext(ctx, g.path, args...) | ||
| 126 | + // Never a password prompt: this runs from a command line that asked for a | ||
| 127 | + // URL, and a repository that needs one is reported, not waited on. | ||
| 128 | + cmd.Env = append(cmd.Environ(), "GIT_TERMINAL_PROMPT=0") | ||
| 129 | + | ||
| 130 | + out, err := cmd.Output() | ||
| 131 | + if err != nil { | ||
| 132 | + var exit *exec.ExitError | ||
| 133 | + if errors.As(err, &exit) && len(exit.Stderr) > 0 { | ||
| 134 | + return "", fmt.Errorf("configrepo: git %s: %s", args[0], strings.TrimSpace(string(exit.Stderr))) | ||
| 135 | + } | ||
| 136 | + return "", fmt.Errorf("configrepo: git %s: %w", args[0], err) | ||
| 137 | + } | ||
| 138 | + return string(out), nil | ||
| 139 | +} | ||
modified
docs/en/reference/app.md +4 -0 | @@ -32,6 +32,10 @@ | ||
| 32 | 32 | | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. | |
| 33 | 33 | | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. | |
| 34 | 34 | |
| 35 | +A file that another program rewrites while it is open — a formatter, a generator, a coding agent working in another terminal — is **re-read into its window without closing and reopening it**, and the status bar says `Reloaded main.go`. The cursor stays where it was, clamped into the new text. The disk is looked at once a second while the editor is idle, and on every turn of the event loop otherwise; the check is one `stat` per open window, so a file whose size and modification time have not moved is never read. | |
| 36 | + | |
| 37 | +A window **with unsaved changes is never reloaded**: the status bar says `main.go changed on disk; your unsaved changes are kept`, once per change, and the next save writes the window over the file. A file that disappears from disk is left as it is in the window, silently, and picked up again when it is back. A reload tells the language server what the file now says, as typing would have. | |
| 38 | + | |
| 35 | 39 | ## Settings |
| 36 | 40 | |
| 37 | 41 | | Method | Description | |
| @@ -32,6 +32,10 @@ | |||
| 32 | | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. | | 32 | | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. | |
| 33 | | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. | | 33 | | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. | |
| 34 | 34 | ||
| 35 | +A file that another program rewrites while it is open — a formatter, a generator, a coding agent working in another terminal — is **re-read into its window without closing and reopening it**, and the status bar says `Reloaded main.go`. The cursor stays where it was, clamped into the new text. The disk is looked at once a second while the editor is idle, and on every turn of the event loop otherwise; the check is one `stat` per open window, so a file whose size and modification time have not moved is never read. | ||
| 36 | + | ||
| 37 | +A window **with unsaved changes is never reloaded**: the status bar says `main.go changed on disk; your unsaved changes are kept`, once per change, and the next save writes the window over the file. A file that disappears from disk is left as it is in the window, silently, and picked up again when it is back. A reload tells the language server what the file now says, as typing would have. | ||
| 38 | + | ||
| 35 | ## Settings | 39 | ## Settings |
| 36 | 40 | ||
| 37 | | Method | Description | | 41 | | Method | Description | |
modified
docs/en/reference/packages.md +3 -1 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | > Neutral, exhaustive description of what turbo-core holds. |
| 4 | 4 | |
| 5 | -Eighteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one. | |
| 5 | +Nineteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one. | |
| 6 | 6 | |
| 7 | 7 | ``` |
| 8 | 8 | app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version} |
| @@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | ||
| 12 | 12 | snippets → {toml, projectfile, profile} |
| 13 | 13 | settings → {toml, projectfile, profile} |
| 14 | 14 | tools → {toml, projectfile, profile} |
| 15 | + configrepo → {projectfile, profile} ← runs the git command | |
| 15 | 16 | syntax → theme |
| 16 | 17 | ui → theme |
| 17 | 18 | theme → {profile-free; takes a directory}, tcell, toml |
| @@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | ||
| 23 | 24 | | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** | |
| 24 | 25 | | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** | |
| 25 | 26 | | `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** | |
| 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 | | |
| 26 | 28 | | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** | |
| 27 | 29 | | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** | |
| 28 | 30 | | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | > Neutral, exhaustive description of what turbo-core holds. | 3 | > Neutral, exhaustive description of what turbo-core holds. |
| 4 | 4 | ||
| 5 | -Eighteen packages. Dependencies run strictly downwards; there are no cycles and no interface indirection to prevent one. | 5 | +Nineteen 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} |
| @@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | |||
| 12 | snippets → {toml, projectfile, profile} | 12 | snippets → {toml, projectfile, profile} |
| 13 | settings → {toml, projectfile, profile} | 13 | settings → {toml, projectfile, profile} |
| 14 | tools → {toml, projectfile, profile} | 14 | tools → {toml, projectfile, profile} |
| 15 | + configrepo → {projectfile, profile} ← runs the git command | ||
| 15 | syntax → theme | 16 | syntax → theme |
| 16 | ui → theme | 17 | ui → theme |
| 17 | theme → {profile-free; takes a directory}, tcell, toml | 18 | theme → {profile-free; takes a directory}, tcell, toml |
| @@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | |||
| 23 | | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** | | 24 | | `profile` | The editor's identity: name, slug, language server, starter templates, and the paths derived from them | **stdlib only** | |
| 24 | | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** | | 25 | | `buffer` | The text of one file: lines of runes, cursor, selection, undo, search, load and save | **stdlib only** | |
| 25 | | `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** | | 26 | | `projectfile` | Atomic writes of the TOML files a project keeps in the editor's own directory | **stdlib only** | |
| 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 | | ||
| 26 | | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** | | 28 | | `version` | What build of itself a binary is: linker stamp, then Go build info | **stdlib only** | |
| 27 | | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** | | 29 | | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** | |
| 28 | | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` | | 30 | | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` | |
modified
docs/fr/reference/app.md +4 -0 | @@ -32,6 +32,10 @@ | ||
| 32 | 32 | | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. | |
| 33 | 33 | | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. | |
| 34 | 34 | |
| 35 | +Un fichier qu'un autre programme réécrit pendant qu'il est ouvert — un formateur, un générateur, un agent de code dans un autre terminal — est **relu dans sa fenêtre sans qu'il faille la fermer et la rouvrir**, et la barre d'état dit `Reloaded main.go`. Le curseur reste où il était, ramené dans le nouveau texte. Le disque est regardé une fois par seconde quand l'éditeur est inactif, et à chaque tour de la boucle d'événements sinon ; la vérification coûte un `stat` par fenêtre ouverte, si bien qu'un fichier dont la taille et la date de modification n'ont pas bougé n'est jamais lu. | |
| 36 | + | |
| 37 | +Une fenêtre **qui a des modifications non enregistrées n'est jamais rechargée** : la barre d'état dit `main.go changed on disk; your unsaved changes are kept`, une fois par changement, et le prochain enregistrement écrit la fenêtre par-dessus le fichier. Un fichier qui disparaît du disque est laissé tel quel dans sa fenêtre, sans un mot, et repris quand il revient. Un rechargement dit au serveur de langage ce que le fichier contient désormais, comme l'aurait fait la frappe. | |
| 38 | + | |
| 35 | 39 | ## Réglages |
| 36 | 40 | |
| 37 | 41 | | Méthode | Description | |
| @@ -32,6 +32,10 @@ | |||
| 32 | | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. | | 32 | | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. | |
| 33 | | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. | | 33 | | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. | |
| 34 | 34 | ||
| 35 | +Un fichier qu'un autre programme réécrit pendant qu'il est ouvert — un formateur, un générateur, un agent de code dans un autre terminal — est **relu dans sa fenêtre sans qu'il faille la fermer et la rouvrir**, et la barre d'état dit `Reloaded main.go`. Le curseur reste où il était, ramené dans le nouveau texte. Le disque est regardé une fois par seconde quand l'éditeur est inactif, et à chaque tour de la boucle d'événements sinon ; la vérification coûte un `stat` par fenêtre ouverte, si bien qu'un fichier dont la taille et la date de modification n'ont pas bougé n'est jamais lu. | ||
| 36 | + | ||
| 37 | +Une fenêtre **qui a des modifications non enregistrées n'est jamais rechargée** : la barre d'état dit `main.go changed on disk; your unsaved changes are kept`, une fois par changement, et le prochain enregistrement écrit la fenêtre par-dessus le fichier. Un fichier qui disparaît du disque est laissé tel quel dans sa fenêtre, sans un mot, et repris quand il revient. Un rechargement dit au serveur de langage ce que le fichier contient désormais, comme l'aurait fait la frappe. | ||
| 38 | + | ||
| 35 | ## Réglages | 39 | ## Réglages |
| 36 | 40 | ||
| 37 | | Méthode | Description | | 41 | | Méthode | Description | |
modified
docs/fr/reference/packages.md +3 -1 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | > Description neutre et exhaustive de ce que contient turbo-core. |
| 4 | 4 | |
| 5 | -Dix-huit 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 | +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. | |
| 6 | 6 | |
| 7 | 7 | ``` |
| 8 | 8 | app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, syntax, theme, profile, version} |
| @@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | ||
| 12 | 12 | snippets → {toml, projectfile, profile} |
| 13 | 13 | settings → {toml, projectfile, profile} |
| 14 | 14 | tools → {toml, projectfile, profile} |
| 15 | + configrepo → {projectfile, profile} ← lance la commande git | |
| 15 | 16 | syntax → theme |
| 16 | 17 | ui → theme |
| 17 | 18 | theme → {sans profile ; reçoit un répertoire}, tcell, toml |
| @@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | ||
| 23 | 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** | |
| 24 | 25 | | `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **bibliothèque standard seule** | |
| 25 | 26 | | `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **bibliothèque standard seule** | |
| 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` | | |
| 26 | 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** | |
| 27 | 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** | |
| 28 | 30 | | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` | |
| @@ -2,7 +2,7 @@ | |||
| 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-huit 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 | +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. |
| 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} |
| @@ -12,6 +12,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | |||
| 12 | snippets → {toml, projectfile, profile} | 12 | snippets → {toml, projectfile, profile} |
| 13 | settings → {toml, projectfile, profile} | 13 | settings → {toml, projectfile, profile} |
| 14 | tools → {toml, projectfile, profile} | 14 | tools → {toml, projectfile, profile} |
| 15 | + configrepo → {projectfile, profile} ← lance la commande git | ||
| 15 | syntax → theme | 16 | syntax → theme |
| 16 | ui → theme | 17 | ui → theme |
| 17 | theme → {sans profile ; reçoit un répertoire}, tcell, toml | 18 | theme → {sans profile ; reçoit un répertoire}, tcell, toml |
| @@ -23,6 +24,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | |||
| 23 | | `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** | | 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** | |
| 24 | | `buffer` | Le texte d'un fichier : lignes de runes, curseur, sélection, annulation, recherche, chargement et sauvegarde | **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** | |
| 25 | | `projectfile` | Écritures atomiques des fichiers TOML qu'un projet garde dans le répertoire de l'éditeur | **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** | |
| 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` | | ||
| 26 | | `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** | | 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** | |
| 27 | | `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** | | 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** | |
| 28 | | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` | | 30 | | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` | |