Files
gitea-nova/CLAUDE.md
T
thatguygriffandClaude Opus 5 4bc81b59e7
CI / Tests (push) Successful in 1m34s
CI / Generated images (push) Successful in 2m0s
Keep the sidebar's open rows across refreshes
Expanding a workflow run and waiting through one poll closed it again.

Two causes. Nova applies whatever collapsible state getTreeItem returns,
and it returned Collapsed for every row that was not built default-open,
so each reload closed everything the user had opened. On top of that, a
reload fired on every poll whether or not anything had changed, so an
idle tree was rebuilt every 15 to 60 seconds for no reason.

The provider now tracks expansion by node identifier, fed from the tree
view's expand and collapse events. A node built default-open seeds that
set the first time its identifier is seen, so such a section can still be
closed by hand and stay closed.

Node identifiers consequently have to be stable across rebuilds. The
fallback for nodes without an explicit one was a counter, which changed
on every build; it is now derived from the node's kind and name. Step
rows were keyed by step number alone, which collided between jobs, and
are now qualified by the job.

Reloads are also skipped entirely when store.signature() is unchanged.
The signature covers everything drawn, including the branch filter, which
changes the tree without changing any data. A live run contributes a
coarse time bucket so its elapsed clock still ticks, and the explicit
Refresh command always redraws.

Adds twelve checks covering expansion surviving a rebuild, a default-open
section staying closed once closed, and the signature responding to run
state and the branch filter but not to a no-op poll. 143 checks total.

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

10 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-PR pullRequestDetail, secrets, variables
  • onDidChangemain.js debounces 150ms, compares store.signature(), and reloads only when it changed. An idle poll therefore leaves the tree, its scroll position, and its open rows completely alone. Anything that changes what is drawn has to appear in that signature — the branch filter is in there for exactly that reason, and a live run contributes a coarse time bucket so its elapsed clock keeps moving.
  • 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 TreeItems 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.

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.

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.