# 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 → every provider's `invalidate()` + `reload()` - 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`. **`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//.png` + `@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.