Files
thatguygriffandClaude Opus 5 cf97f0dc2a
CI / Tests (push) Successful in 11s
CI / Generated images (push) Successful in 41s
Reload only the rows that changed
Refreshing rebuilt the whole tree, and TreeView.reload() with no
argument drops the scroll position. Watching a job meant the view jumped
every fifteen seconds.

The provider now reconciles instead of replacing. update() rebuilds from
the store, matches rows to the existing nodes by identifier, copies the
rendered fields onto them, and reports the topmost rows that actually
changed; main.js reloads just those. Node identity survives the merge,
which is what makes a targeted reload possible. An open row that fetches
its own children has that cache dropped so it refetches — the running-job
path. A job finishing now reloads one run's row and nothing else.

Sections also each describe what they draw, so a section only rebuilds
when its own content moved: run activity no longer disturbs the pull
request list, and an idle tree is left alone entirely. This does not slow
anything down — a section showing a run in progress still updates on
every poll, and contributes a time bucket so its elapsed clock keeps
moving.

A row set changing at the root still needs a whole-tree reload; there the
selected row is re-revealed afterwards, which is the closest thing to a
scroll anchor Nova exposes.

Adds seventeen checks: no-op updates reporting nothing, a finishing job
naming only its own row, node identity surviving, a new run reloading
just its workflow group, and a new workflow escalating to a full reload.
168 checks total.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MQuusXgZC2dzwpJJ1qhtti
2026-08-28 20:30:17 -03:00

243 lines
12 KiB
Markdown

# CLAUDE.md
Working notes for this extension. The [README](README.md) is for users; this is
for whoever picks the code up next. It records the things that are not obvious
from reading the source.
## What this is
A Nova (Panic's macOS editor) extension giving Gitea Actions, pull requests,
reviews, secrets, and variables a sidebar. It aims at feature parity with the
official [Gitea for VS Code](https://gitea.com/gitea/gitea-vscode) extension,
adapted to what Nova's API actually allows.
Plain CommonJS. No build step, no bundler, no dependencies — Nova runs
`Scripts/` directly.
## Verifying a change
```sh
Tests/run.sh # 131 checks, ~3s, no network
find Scripts Tests -name '*.js' -print0 | xargs -0 -n1 node --check
python3 -c "import json; json.load(open('extension.json'))"
```
Nova itself will not tell you about a malformed `extension.json` beyond refusing
to load, so validate it. `.gitea/workflows/ci.yml` runs exactly these checks
plus `python3 Tools/make-icons.py --check`, which verifies the committed images
still match the shapes they are generated from. That check compares
decompressed pixels rather than file bytes, because zlib output differs between
versions and a byte comparison would fail spuriously.
If CI fails on the runner label, `runs-on` in that workflow is the only thing to
change — the jobs need nothing but node, python3, and git.
To try the real thing:
```sh
ln -s "$PWD" ~/Library/Application\ Support/Nova/Extensions/unsupervised.giteanova
```
Then **Extensions → Extension Library → Gitea → Console**, with **Enable Debug
Logging** on.
## Layout and layering
```
Scripts/
main.js activate/deactivate, TreeViews, preference observers
store.js all state; polling; the only thing that fetches on a timer
ui.js promise wrappers for Nova's callback panels; file helpers
util/ log, config, time, exec, git (no internal deps)
gitea/ remotes, client, api, credentials, router, discovery
views/ nodes, provider, builders, one file per sidebar section
controllers/ one file per command group; selection.js normalises arguments
```
Dependencies run one way, and it is worth keeping them that way:
```
util → gitea → store → views → controllers → main
```
`util/` requires nothing internal. `views/builders.js` requires `store.js` only
for `isActiveRun`; that is the deepest upward reach in the codebase.
## The pieces that matter
**`store.js`** is the single source of truth. Views read it and never fetch.
Controllers mutate through it. It owns:
- `repos`, `unmatchedHosts`, `instanceStatus`
- per-run `jobs`/`artifacts`, per-PR `pullRequestDetail`, `secrets`, `variables`
- `onDidChange``main.js` debounces 150ms, then asks each section whether it
needs anything (see **Redrawing** below)
- adaptive polling: `runningInterval` when anything is queued or running, else
`idleInterval`. `refresh()` coalesces — a second call while one is in flight
returns the same promise rather than stacking.
**`views/provider.js`** — every section subclasses `NodeProvider` and implements
only `roots()`. Nodes are plain objects (`views/nodes.js` `node()`), turned into
`TreeItem`s centrally. A node with `load` fetches when expanded; the result is
cached onto `element.children`.
It also owns **expansion state**, which is not optional bookkeeping. Nova
applies whatever `collapsibleState` `getTreeItem` returns, so a reload closes
every row the provider does not report as open. `main.js` feeds
`onDidExpandElement`/`onDidCollapseElement` into `setExpanded`, keyed by node
identifier. A node built with `expanded: true` seeds that set the first time its
identifier is seen, so a default-open section can still be closed and stay
closed. **Node identifiers must therefore be stable across rebuilds** — never
derive one from a counter or from anything that changes between refreshes.
## Redrawing
Nova has no API to read or set a tree's scroll offset, and `TreeView.reload()`
with no argument rebuilds every row and drops the scroll position. Polling every
15 seconds while a job runs therefore cannot go through a full reload. Three
things keep the view still, in order of how often they fire:
1. **Per-section signatures.** Each provider implements `signature()` describing
only what it draws. `main.js` compares it and skips the section entirely when
unchanged, so run activity never disturbs the Pull Requests section and an
idle tree is never touched. Anything that changes the drawing must be in
there — the branch filter is, because it changes the tree without changing
any data. A section showing a run in progress adds a coarse time bucket
(`store.elapsedTick`) so its elapsed clock keeps moving.
2. **`provider.update()` reconciles in place.** It rebuilds from the store and
merges the result onto the node objects Nova already holds, matching rows by
identifier and copying the rendered fields across. Node identity survives,
which is what makes a targeted reload possible at all.
3. **Targeted `view.reload(node)`.** `update()` returns the topmost rows that
actually changed, and only those are reloaded. Watching a running job
reloads that one run's row, nothing else.
`update()` returns `{ full: true }` when a row set changed at the root and the
merge cannot patch it; only then does a whole-tree reload happen, and
`fullReload` re-reveals the selected row afterwards as the nearest thing to a
scroll anchor Nova offers.
An open row whose children are lazily loaded has its `children` cache dropped by
the merge, so the reload refetches — that is the running-job path. Keep that.
**`controllers/selection.js`** — a command can arrive from a sidebar (Nova
passes the `TreeView`), from a node directly, or from a menu with no argument at
all. `selectedNode()` normalises all three; `resolveRepo()` falls back to the
only repo, or prompts. Use these rather than reading arguments yourself.
## Nova API constraints, learned the hard way
- **`when` clauses are a strict subset of JavaScript.** `&&`, `||`, `!`, `==`,
`!=`, parentheses. **No regex, no `in`.** Write
`viewItem == 'a' || viewItem == 'b'`. A bad clause fails silently.
- **No webview, no diff editor, no editor decorations, no status bar** for
extensions. Hence: the PR timeline is Markdown in a tab, diffs are `.diff`
documents, inline review comments go through `IssueCollection`, and a failed
run posts a notification.
- **Images** live at `Images/<name>/<name>.png` + `<name>@2x.png` +
`metadata.json` with `{"template": true}`. Referenced by folder name. All of
`Images/` is generated by `Tools/make-icons.py` — edit the shapes there and
re-run it, don't hand-edit PNGs. CI enforces this with `--check`.
- **Entitlements** in `extension.json` gate `fetch`, `Process`, filesystem, and
clipboard. Adding a capability means adding the entitlement.
- `fetch`, `setTimeout`, `TextEncoder`, `atob`/`btoa` exist. There is no `URL`
guarantee — `gitea/client.js` builds query strings by hand for that reason.
- `nova.credentials` is a **synchronous Keychain call**. See caching below.
- `TreeItem.command` fires on double-click and receives the `TreeView`.
- Menu separators are `{"separator": true}`.
## Caching invariants
Three caches, each with an invalidation rule that is easy to break:
1. **`gitea/credentials.js` memoises tokens.** The sidebar asks `hasToken()` on
every render, and each miss is a synchronous Keychain hit. **Always go
through `credentials.setToken`/`removeToken`** — writing `nova.credentials`
directly leaves the memo stale. (A test did exactly this and produced a
convincing-looking failure that was not a product bug.)
2. **`InstanceRouter`** caches API clients, the parsed alias map, and the set of
already-probed hosts. `router.invalidate()` clears all three plus the token
memo. Call it after anything that changes instance configuration.
3. **`store`** caches run detail and PR detail. The lazy loaders in
`views/builders.js` reuse them so a 15-second reload does not refetch every
expanded row — a run refetches only while still active, a PR only when its
`updated_at` moves. Preserve that when touching the loaders.
`store._pruneCaches()` derives repo keys by slicing at the **last** `:` — repo
keys contain `://`, so it has to be `lastIndexOf`.
## Host aliases
Some Gitea instances answer SSH on a different hostname than the web UI, which
breaks host-based routing. Resolution order in `discovery.js`:
1. `router.baseUrlForHost(host)` — direct match, then the alias map.
2. `router.detectHostAlias(host, owner, name)` — asks each tokened instance for
the repository and compares the `ssh_url`/`clone_url`/`html_url` it
advertises. On a match it persists a `host = instance` entry via
`config.addHostAlias` and returns the instance.
3. Otherwise the host lands in `store.unmatchedHosts` and the sidebar shows a
row offering `gitea.addHostAlias`.
Each host is probed at most once per session (`_probedHosts`). `addHostAlias`
returns `false` and writes nothing when the preference already says this — that
matters because `gitea.hostAliases` is in `main.js`'s `REDISCOVERY_KEYS`, and an
unconditional write would re-trigger discovery.
## Adding a command
1. `nova.commands.register("gitea.thing", handler)` in the right controller.
2. Add it to `extension.json` — a `commands.extensions`/`commands.editor` entry,
a sidebar `headerCommands`/`contextCommands` entry, or a `config` button.
3. If it acts on a selection, give the node a `contextValue` and gate it with a
`when` clause.
`integration.test.js` asserts both directions: every manifest command is
registered, and every registered command appears in the manifest. The single
allowed exception is `gitea.openReviewComment`, a double-click handler with no
menu entry — if you add another, update that assertion deliberately.
## Gitea API notes
Endpoints were taken from Gitea's published `swagger.v1.json` (fetch it from any
instance at `/swagger.v1.json`). Worth knowing:
- Re-running one job is `POST /actions/runs/{run}/jobs/{job_id}/rerun` — under
the run, not under `/actions/jobs/`.
- `/actions/runs` arrived in Gitea 1.24. `api.listRuns` catches the 404 and
falls back to `/actions/tasks`, flagged as `legacy: true`, which the views
render as a flat "Recent Runs" list. Keep that fallback.
- Secrets are `PUT` (create and update alike) and never read back — an "update"
is just another `PUT`. Variables use `POST` to create and `PUT` to update.
- `/actions/artifacts` and `/actions/workflows` 404 on older servers;
`listArtifacts`/`listWorkflows` return `[]` rather than throwing, so one old
server does not blank the tree.
- Review comments carry `diff_hunk`, not a file line.
`controllers/review.js` `lineFromDiffHunk` derives the line: the commented
line is the hunk's last, and `-` lines do not advance the new file. This is
easy to get wrong — check it by hand before assuming a test is wrong.
- Go's zero time (`0001-01-01T00:00:00Z`) means "never". `util/time.js` returns
`""` for it; never render it as a date.
## Tests
Three suites under `Tests/`, run with `Tests/run.sh`. No npm install, no
network: `nova-stub.js` stands in for the Nova runtime and `fake-gitea.js`
serves a canned Gitea 1.27 through a `fetch()` stub. Git fixtures are built in
`mkdtemp` directories and removed afterwards — nothing is written inside the
repo. See [Tests/README.md](Tests/README.md).
When adding coverage, prefer extending `fake-gitea.js`'s fixtures over mocking a
module: the suites exercise the real code paths, which is what makes them worth
having.
## Deliberately not implemented
- **OAuth sign-in.** Gitea's flow needs a redirect to a local HTTP listener;
Nova extensions cannot listen on a socket. Tokens only.
- **`tls.insecureSkipVerify`.** Nova's `fetch` exposes no TLS override, so the
setting would be a lie. Omitted rather than shipped as a no-op.
Both are recorded in the README's comparison table. If Nova ever gains the
capability, that table is the place to start.