# 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//.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.