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
243 lines
12 KiB
Markdown
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.
|