Discovery matched a git remote's host against the instance URL's host, so a server answering SSH on a different name than its web UI resolved to nothing — and said so only in a debug log. Resolution now runs in three stages. A remote host is matched directly, then against configured aliases, and failing both the instances are asked for the repository: Gitea publishes its SSH hostname in a repository's ssh_url, so the right instance identifies itself. What that turns up is saved as a host alias, so later repositories on the same host resolve with no lookup at all, and the mapping is visible and editable rather than hidden. Each unknown host is probed at most once per session. When nothing resolves the sidebar now names the unmatched host and offers Add Host Alias, instead of showing an empty section. Aliases can also be written by hand as "remote-host = instance URL", accepting =, -> and =>, ignoring ports, and skipping # comments. List preferences now merge workspace entries onto global ones rather than letting an empty global array mask them. Adds Tests/host-aliases.test.js covering both directions: unmatched hosts reported and nothing persisted, a hand-written alias, detection from ssh_url, and a later repository resolving from the stored alias without a probe. 131 checks across three suites. Also adds CLAUDE.md, and .gitea/workflows/ci.yml running the suites, script syntax checks, manifest validation, and a generated-image check. Tools/make-icons.py gains --check, which compares decompressed pixels so a differing zlib version cannot fail it spuriously. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MQuusXgZC2dzwpJJ1qhtti
9.6 KiB
CLAUDE.md
Working notes for this extension. The README 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 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
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:
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-PRpullRequestDetail,secrets,variables onDidChange→main.jsdebounces 150ms → every provider'sinvalidate()+reload()- adaptive polling:
runningIntervalwhen anything is queued or running, elseidleInterval.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
TreeItems 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
whenclauses are a strict subset of JavaScript.&&,||,!,==,!=, parentheses. No regex, noin. WriteviewItem == '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
.diffdocuments, inline review comments go throughIssueCollection, and a failed run posts a notification. - Images live at
Images/<name>/<name>.png+<name>@2x.png+metadata.jsonwith{"template": true}. Referenced by folder name. All ofImages/is generated byTools/make-icons.py— edit the shapes there and re-run it, don't hand-edit PNGs. CI enforces this with--check. - Entitlements in
extension.jsongatefetch,Process, filesystem, and clipboard. Adding a capability means adding the entitlement. fetch,setTimeout,TextEncoder,atob/btoaexist. There is noURLguarantee —gitea/client.jsbuilds query strings by hand for that reason.nova.credentialsis a synchronous Keychain call. See caching below.TreeItem.commandfires on double-click and receives theTreeView.- Menu separators are
{"separator": true}.
Caching invariants
Three caches, each with an invalidation rule that is easy to break:
gitea/credentials.jsmemoises tokens. The sidebar askshasToken()on every render, and each miss is a synchronous Keychain hit. Always go throughcredentials.setToken/removeToken— writingnova.credentialsdirectly leaves the memo stale. (A test did exactly this and produced a convincing-looking failure that was not a product bug.)InstanceRoutercaches 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.storecaches run detail and PR detail. The lazy loaders inviews/builders.jsreuse them so a 15-second reload does not refetch every expanded row — a run refetches only while still active, a PR only when itsupdated_atmoves. 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:
router.baseUrlForHost(host)— direct match, then the alias map.router.detectHostAlias(host, owner, name)— asks each tokened instance for the repository and compares thessh_url/clone_url/html_urlit advertises. On a match it persists ahost = instanceentry viaconfig.addHostAliasand returns the instance.- Otherwise the host lands in
store.unmatchedHostsand the sidebar shows a row offeringgitea.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
nova.commands.register("gitea.thing", handler)in the right controller.- Add it to
extension.json— acommands.extensions/commands.editorentry, a sidebarheaderCommands/contextCommandsentry, or aconfigbutton. - If it acts on a selection, give the node a
contextValueand gate it with awhenclause.
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/runsarrived in Gitea 1.24.api.listRunscatches the 404 and falls back to/actions/tasks, flagged aslegacy: 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 anotherPUT. Variables usePOSTto create andPUTto update. /actions/artifactsand/actions/workflows404 on older servers;listArtifacts/listWorkflowsreturn[]rather than throwing, so one old server does not blank the tree.- Review comments carry
diff_hunk, not a file line.controllers/review.jslineFromDiffHunkderives 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.jsreturns""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.
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'sfetchexposes 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.