diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e79b9ee --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,51 @@ +# +# Runs the test suites on every push and pull request. +# +# `runs-on` uses the label most Gitea runners register by default. If yours +# registers something else, change it here — nothing in these jobs depends on a +# particular image beyond node, python3, and git. +# +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Validate the extension manifest + run: node -e "JSON.parse(require('fs').readFileSync('extension.json', 'utf8'))" + + - name: Syntax-check every script + run: find Scripts Tests -name '*.js' -print0 | xargs -0 -n1 node --check + + # No install step: the Nova runtime and the Gitea server are both stubbed, + # and the git fixtures are built in temporary directories. + - name: Run the test suites + run: Tests/run.sh + + images: + name: Generated images + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + # Images/ is generated from the shapes in Tools/make-icons.py. --check + # compares decompressed pixels rather than file bytes, so a different + # zlib version cannot fail this spuriously. + - name: Check the committed images match their source + run: python3 Tools/make-icons.py --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 875657c..2f46092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,4 +21,11 @@ Initial release. - Review comments for the current branch published as editor issues. - Multiple Gitea instances, routed by git remote host, with tokens stored per instance in the macOS Keychain. +- Support for instances that answer SSH on a different hostname than their web + UI. The mapping is detected from the `ssh_url` a repository advertises and + saved as a host alias, so later repositories on that host resolve without a + lookup; it can also be set by hand, and an unresolved host is reported in the + sidebar with an action to fix it. - Adaptive polling and a notification when a workflow run fails. +- Gitea Actions CI running the test suites, script syntax checks, manifest + validation, and a generated-image check. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a73c4c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,203 @@ +# 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. diff --git a/README.md b/README.md index ec3c287..3a09786 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,32 @@ workspace. A remote is matched to an instance by its host, so `https://gitea.example.com`. Set **Discovery Mode** to *All Accessible Repositories* to list everything your token can reach instead. +### When SSH uses a different hostname + +Some instances answer SSH on a different hostname than the web UI — remotes look +like `git@ssh.example.net:acme/widget.git` while the instance is at +`https://git.example.com`. The hosts do not match, so those repositories would +not be found. + +The extension resolves this on its own: the first time it sees an unknown remote +host, it asks each configured instance for the repository and compares the +`ssh_url` that instance advertises. On a match it records the alias in the +**Host Aliases** preference, so every later repository on that host resolves +immediately, with no further lookups and nothing to configure. + +That needs a token, and the repository has to be one your token can read. If it +cannot be worked out, the sidebar shows the unresolved host with an **Add Host +Alias…** action instead of failing silently. You can also add one by hand, as +`remote-host = instance URL`, one per line: + +``` +ssh.example.net = https://git.example.com +``` + +Aliases show under their instance in the **Settings** section, so a detected one +is visible rather than invisible magic. `=`, `->`, and `=>` all work as +separators, ports are ignored, and lines starting with `#` are comments. + ## Sidebar sections **Current Branch** — pull requests and workflow runs for the branch checked out @@ -110,6 +136,7 @@ features are shaped differently: | --- | --- | --- | | Default Instance URL | `https://gitea.com` | Instance used when no other matches a remote | | Additional Instances | — | Extra instance URLs | +| Host Aliases | — | Map alternate remote hosts onto an instance; filled in automatically when detected | | Discovery Mode | `workspace` | Workspace git remotes, or all accessible repositories | | Max Runs per Repository | 20 | How many runs to load | | Max Jobs per Run | 50 | How many jobs to load | @@ -144,19 +171,27 @@ Turn on **Enable Debug Logging** and watch the extension console Tests/run.sh ``` -Two suites run the extension's real code under plain Node against a stubbed Nova +Three suites run the extension's real code under plain Node against a stubbed Nova runtime and a canned Gitea instance — no install step, no network, and nothing written inside the repository. See [Tests/README.md](Tests/README.md). ### Images `Images/` is generated. To change an icon, edit the shapes in -`Tools/make-icons.py` and re-run it from the extension root: +`Tools/make-icons.py` and re-run it: ```sh -python3 Tools/make-icons.py +python3 Tools/make-icons.py # regenerate +python3 Tools/make-icons.py --check # verify, without writing ``` +### Continuous integration + +`.gitea/workflows/ci.yml` runs the suites, syntax-checks every script, +validates the manifest, and verifies the generated images on each push and pull +request. Adjust `runs-on` if your runner registers a label other than +`ubuntu-latest`; the jobs need only node, python3, and git. + ## License MIT diff --git a/Scripts/controllers/auth.js b/Scripts/controllers/auth.js index ee7707d..2140cf4 100644 --- a/Scripts/controllers/auth.js +++ b/Scripts/controllers/auth.js @@ -139,6 +139,40 @@ function register(store, refresh) { await refresh({ rediscover: true }); }); + // Reachable from the warning row a repository shows when its remote host + // matches nothing, which is where users actually hit this. + nova.commands.register("gitea.addHostAlias", async (argument) => { + const node = selection.selectedNode(argument); + const suggested = node && node.data ? node.data.host : null; + + const host = + suggested || + (await ui.input("Which git remote host should be mapped?", { + placeholder: "ssh.example.com", + prompt: + "The hostname your git remotes use, when it differs from the " + + "instance's web address.", + })); + if (!host) return; + + const urls = config.instances(); + if (!urls.length) { + ui.warn("Configure a Gitea instance before adding an alias for one."); + return; + } + + const baseUrl = + urls.length === 1 + ? urls[0] + : await ui.choose(urls, { placeholder: `Map ${host} onto which instance?` }); + if (!baseUrl) return; + + config.addHostAlias(host, baseUrl); + store.router.invalidate(); + ui.info(`Remotes on ${host} now resolve to ${baseUrl}.`); + await refresh({ rediscover: true }); + }); + nova.commands.register("gitea.openPreferences", () => { nova.openConfig(); }); diff --git a/Scripts/gitea/discovery.js b/Scripts/gitea/discovery.js index 61ef3d7..e9fd975 100644 --- a/Scripts/gitea/discovery.js +++ b/Scripts/gitea/discovery.js @@ -33,10 +33,11 @@ function makeRepo({ baseUrl, owner, name, dir = null, remoteName = null, remoteU async function discoverFromWorkspace(router) { const root = nova.workspace.path; - if (!root) return []; + if (!root) return { repos: [], unmatched: [] }; const dirs = await git.discoverRepositories(root); const found = new Map(); + const unmatched = new Map(); for (const dir of dirs) { const list = await git.remotes(dir); @@ -50,13 +51,22 @@ async function discoverFromWorkspace(router) { return 0; }); + let matched = false; + const skipped = []; + for (const remote of ordered) { const parsed = remotes.parse(remote.url); if (!parsed) continue; - const baseUrl = router.baseUrlForHost(parsed.host); + // A configured or already-learned host resolves immediately; + // otherwise ask the instances whether one of them serves it. + const baseUrl = + router.baseUrlForHost(parsed.host) || + (await router.detectHostAlias(parsed.host, parsed.owner, parsed.name)); + if (!baseUrl) { log.debug(`no configured instance for host ${parsed.host} (${remote.url})`); + skipped.push({ host: parsed.host, remoteUrl: remote.url, dir: dir }); continue; } @@ -74,11 +84,20 @@ async function discoverFromWorkspace(router) { repo.headSha = await git.headSha(dir); found.set(repo.key, repo); } + matched = true; break; } + + // Only report hosts for a checkout that resolved to nothing at all; a + // repository with a matching origin plus an unrelated fork is fine. + if (!matched) { + for (const entry of skipped) { + if (!unmatched.has(entry.host)) unmatched.set(entry.host, entry); + } + } } - return [...found.values()]; + return { repos: [...found.values()], unmatched: [...unmatched.values()] }; } async function discoverFromApi(router) { @@ -108,15 +127,16 @@ async function discoverFromApi(router) { } /** - * Discovers repositories for the configured mode. Workspace mode additionally + * Discovers repositories for the configured mode, returning them alongside any + * remote hosts that matched no configured instance. Workspace mode additionally * merges in local checkouts so allAccessible never loses the local directory * needed for checkout and log-saving. */ async function discover(router) { const mode = config.discoveryMode(); - const workspaceRepos = await discoverFromWorkspace(router); + const { repos: workspaceRepos, unmatched } = await discoverFromWorkspace(router); - if (mode !== "allAccessible") return workspaceRepos; + if (mode !== "allAccessible") return { repos: workspaceRepos, unmatched: unmatched }; const byKey = new Map(); for (const repo of await discoverFromApi(router)) byKey.set(repo.key, repo); @@ -133,7 +153,10 @@ async function discover(router) { } } - return [...byKey.values()].sort((a, b) => a.fullName.localeCompare(b.fullName)); + return { + repos: [...byKey.values()].sort((a, b) => a.fullName.localeCompare(b.fullName)), + unmatched: unmatched, + }; } exports.discover = discover; diff --git a/Scripts/gitea/remotes.js b/Scripts/gitea/remotes.js index 4c489e0..ba6a774 100644 --- a/Scripts/gitea/remotes.js +++ b/Scripts/gitea/remotes.js @@ -57,11 +57,22 @@ function parse(url) { return null; } -/** The host of an instance base URL, for matching against a remote's host. */ -function hostOf(baseUrl) { - const match = String(baseUrl || "").match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)/i); - if (!match) return null; - return stripCredentials(match[1]).replace(/:\d+$/, "").toLowerCase(); +/** + * The bare host of an instance base URL, a remote URL, or a plain hostname, + * without scheme, credentials, port, or path. Used to compare a remote against + * a configured instance and to normalise both sides of a host alias. + */ +function hostOf(value) { + const text = String(value || "").trim(); + if (!text) return null; + + const scheme = text.match(/^[a-z][a-z0-9+.-]*:\/\/([^/]+)/i); + // Without a scheme this is already a bare host, possibly carrying + // credentials, a port, or an SCP-style path after a colon. + const authority = scheme ? scheme[1] : text.replace(/[/:].*$/, ""); + + const host = stripCredentials(authority).replace(/:\d+$/, "").toLowerCase(); + return host || null; } exports.parse = parse; diff --git a/Scripts/gitea/router.js b/Scripts/gitea/router.js index c493f5c..25775a1 100644 --- a/Scripts/gitea/router.js +++ b/Scripts/gitea/router.js @@ -1,17 +1,24 @@ // // Instance routing. Repositories are matched to a configured Gitea instance by -// their git remote's host; anything unmatched falls back to the default one. +// their git remote's host, directly or through a configured host alias for +// servers that answer SSH on a different hostname than the web UI. // const config = require("../util/config.js"); const credentials = require("./credentials.js"); +const log = require("../util/log.js"); const remotes = require("./remotes.js"); const { GiteaClient } = require("./client.js"); const { GiteaApi } = require("./api.js"); +// "ssh.example.com = https://git.example.com", also accepting -> and =>. +const ALIAS_SEPARATOR = /\s*(?:=>|->|=)\s*/; + class InstanceRouter { constructor() { this.apis = new Map(); + this._aliases = null; + this._probedHosts = new Set(); } /** Base URLs of every configured instance, default first. */ @@ -36,12 +43,49 @@ class InstanceRouter { return urls.length ? this.apiFor(urls[0]) : null; } - /** The instance whose host matches `host`, or null when none does. */ + /** + * Alternate remote hosts mapped onto instance hosts, parsed from the + * "remote-host = instance" preference entries. Blank lines, comments, and + * malformed entries are ignored rather than breaking discovery. + */ + get aliases() { + if (this._aliases) return this._aliases; + + const map = new Map(); + for (const entry of config.hostAliasEntries()) { + const text = String(entry || "").trim(); + if (!text || text.startsWith("#")) continue; + + const parts = text.split(ALIAS_SEPARATOR); + if (parts.length !== 2) continue; + + const from = remotes.hostOf(parts[0]); + const to = remotes.hostOf(parts[1]); + if (from && to && from !== to) map.set(from, to); + } + + this._aliases = map; + return map; + } + + /** + * The instance whose host matches `host`, directly or through an alias, or + * null when none does. + */ baseUrlForHost(host) { if (!host) return null; - const target = host.toLowerCase(); + const target = String(host).toLowerCase(); + return ( + this._instanceForHost(target) || + this._instanceForHost(this.aliases.get(target)) || + null + ); + } + + _instanceForHost(host) { + if (!host) return null; for (const baseUrl of this.baseUrls) { - if (remotes.hostOf(baseUrl) === target) return baseUrl; + if (remotes.hostOf(baseUrl) === host) return baseUrl; } return null; } @@ -54,8 +98,53 @@ class InstanceRouter { /** Drops cached clients and tokens so the next call re-reads preferences. */ invalidate() { this.apis.clear(); + this._aliases = null; + this._probedHosts.clear(); credentials.invalidate(); } + + /** + * Works out which instance serves `host` by asking each one for the + * repository and comparing the clone URLs it reports. Gitea publishes its + * SSH hostname in a repository's ssh_url, so a server that answers SSH on a + * different name than its web UI identifies itself here. + * + * Probes each host at most once per session and remembers what it learns. + */ + async detectHostAlias(host, owner, name) { + if (!host || !owner || !name) return null; + if (this._probedHosts.has(host)) return null; + this._probedHosts.add(host); + + for (const baseUrl of this.baseUrls) { + if (!credentials.hasToken(baseUrl)) continue; + + let repository; + try { + repository = await this.apiFor(baseUrl).repository({ owner: owner, name: name }); + } catch (error) { + log.debug(`${baseUrl} does not serve ${owner}/${name}:`, String(error.message || error)); + continue; + } + + const advertised = [repository.ssh_url, repository.clone_url, repository.html_url] + .map((url) => remotes.hostOf(url)) + .filter(Boolean); + if (!advertised.includes(host)) continue; + + this.rememberAlias(host, baseUrl); + log.info(`detected that git remotes on ${host} belong to ${baseUrl}`); + return baseUrl; + } + + return null; + } + + /** Records an alias for this session and persists it for the next one. */ + rememberAlias(host, baseUrl) { + this.aliases.set(remotes.hostOf(host), remotes.hostOf(baseUrl)); + config.addHostAlias(host, baseUrl); + } } exports.InstanceRouter = InstanceRouter; diff --git a/Scripts/main.js b/Scripts/main.js index b002e6b..59ea9bb 100644 --- a/Scripts/main.js +++ b/Scripts/main.js @@ -26,7 +26,12 @@ const RELOAD_DEBOUNCE_MS = 150; // Preferences that change which repositories exist, rather than only how they // are displayed, force a full rediscovery. -const REDISCOVERY_KEYS = ["gitea.baseUrl", "gitea.instances", "gitea.discovery.mode"]; +const REDISCOVERY_KEYS = [ + "gitea.baseUrl", + "gitea.instances", + "gitea.hostAliases", + "gitea.discovery.mode", +]; const DISPLAY_KEYS = [ "gitea.maxRunsPerRepo", "gitea.maxJobsPerRun", diff --git a/Scripts/store.js b/Scripts/store.js index 03703b3..22a4215 100644 --- a/Scripts/store.js +++ b/Scripts/store.js @@ -35,6 +35,7 @@ class Store { this.router = new InstanceRouter(); this.repos = []; + this.unmatchedHosts = []; this.instanceStatus = new Map(); this.runs = new Map(); // repoKey -> { runs, legacy, error } this.jobs = new Map(); // repoKey:runId -> jobs @@ -143,7 +144,9 @@ class Store { try { if (rediscover || !this._discovered) { this.router.invalidate(); - this.repos = await discovery.discover(this.router); + const discovered = await discovery.discover(this.router); + this.repos = discovered.repos; + this.unmatchedHosts = discovered.unmatched; this._discovered = true; this._pruneCaches(); } else { diff --git a/Scripts/util/config.js b/Scripts/util/config.js index ac23e6d..2c441c9 100644 --- a/Scripts/util/config.js +++ b/Scripts/util/config.js @@ -45,6 +45,14 @@ function bool(key) { return value === null ? DEFAULTS[key] === true : value === true; } +/** List preferences merge the workspace's entries on top of the global ones. */ +function arrayPref(key) { + const globalEntries = nova.config.get(key, "array") || []; + const workspaceEntries = + (nova.workspace && nova.workspace.config && nova.workspace.config.get(key, "array")) || []; + return [...globalEntries, ...workspaceEntries]; +} + /** Trailing slashes make every later URL join ambiguous, so strip them once here. */ function normalizeBaseUrl(url) { if (!url) return null; @@ -67,12 +75,7 @@ function instances() { }; add(get("gitea.baseUrl", "string")); - - const extra = - nova.config.get("gitea.instances", "array") || - (nova.workspace && nova.workspace.config.get("gitea.instances", "array")) || - []; - for (const url of extra) add(url); + for (const url of arrayPref("gitea.instances")) add(url); return list; } @@ -107,11 +110,46 @@ function setBranchFilter(mode, branch) { } } +/** + * Raw "remote-host = instance" entries. Some servers answer SSH on a different + * hostname than the web UI, which would otherwise leave those remotes matching + * no instance at all. + */ +function hostAliasEntries() { + return arrayPref("gitea.hostAliases"); +} + +/** + * Appends an alias, replacing any existing entry for the same remote host. + * Returns false and writes nothing when the preference already says this, so + * auto-detection cannot bounce the change observers. + */ +function addHostAlias(host, baseUrl) { + const key = String(host).trim().toLowerCase(); + const entry = `${key} = ${baseUrl}`; + + const current = nova.config.get("gitea.hostAliases", "array") || []; + const kept = current.filter((existing) => { + const left = String(existing).split(/\s*(?:=>|->|=)\s*/)[0]; + return String(left).trim().toLowerCase() !== key; + }); + + const next = [...kept, entry]; + if (next.length === current.length && next.every((value, i) => value === current[i])) { + return false; + } + + nova.config.set("gitea.hostAliases", next); + return true; +} + exports.get = get; exports.num = num; exports.bool = bool; exports.instances = instances; exports.setInstances = setInstances; +exports.hostAliasEntries = hostAliasEntries; +exports.addHostAlias = addHostAlias; exports.normalizeBaseUrl = normalizeBaseUrl; exports.branchFilter = branchFilter; exports.setBranchFilter = setBranchFilter; diff --git a/Scripts/views/branchView.js b/Scripts/views/branchView.js index 2f5e2b9..c94a776 100644 --- a/Scripts/views/branchView.js +++ b/Scripts/views/branchView.js @@ -21,6 +21,7 @@ class BranchProvider extends NodeProvider { return [message("No token stored. Run “Gitea → Set Token…” to sign in.")]; } if (!store.repos.length) { + if (store.unmatchedHosts.length) return builders.unmatchedHostNodes(store); return [ message( store.loading diff --git a/Scripts/views/builders.js b/Scripts/views/builders.js index e7af75d..8473dea 100644 --- a/Scripts/views/builders.js +++ b/Scripts/views/builders.js @@ -270,6 +270,30 @@ function pullRequestNode(store, repo, pull, { showRepo = false } = {}) { }); } +/** + * Rows for remote hosts that matched no configured instance. This is what an + * instance serving SSH from a different hostname than the web UI looks like, + * so the row offers the fix directly. + */ +function unmatchedHostNodes(store) { + return store.unmatchedHosts.map((entry) => + node("unmatchedHost", `\u26A0 No Gitea instance for ${entry.host}`, { + identifier: `unmatched-${entry.host}`, + description: "add a host alias", + tooltip: [ + entry.remoteUrl, + "", + "This remote's host matches no configured Gitea instance.", + "If your server answers SSH on a different hostname than its web", + "UI, map one onto the other with an SSH host alias.", + ].join("\n"), + contextValue: "unmatchedHost", + command: "gitea.addHostAlias", + data: { host: entry.host, remoteUrl: entry.remoteUrl }, + }), + ); +} + function firstLine(text) { const line = String(text || "").split("\n").find((entry) => entry.trim()); if (!line) return "(empty comment)"; @@ -282,5 +306,6 @@ exports.artifactNode = artifactNode; exports.pullRequestNode = pullRequestNode; exports.reviewCommentNode = reviewCommentNode; exports.changedFileNode = changedFileNode; +exports.unmatchedHostNodes = unmatchedHostNodes; exports.formatBytes = formatBytes; exports.firstLine = firstLine; diff --git a/Scripts/views/pullRequestsView.js b/Scripts/views/pullRequestsView.js index fded5fa..b086875 100644 --- a/Scripts/views/pullRequestsView.js +++ b/Scripts/views/pullRequestsView.js @@ -17,6 +17,7 @@ class PullRequestsProvider extends NodeProvider { return [message("No token stored. Run “Gitea → Set Token…” to sign in.")]; } if (!store.repos.length) { + if (store.unmatchedHosts.length) return builders.unmatchedHostNodes(store); return [message(store.loading ? "Loading…" : "No Gitea repositories found.")]; } diff --git a/Scripts/views/settingsView.js b/Scripts/views/settingsView.js index 014a21a..ec00374 100644 --- a/Scripts/views/settingsView.js +++ b/Scripts/views/settingsView.js @@ -53,6 +53,22 @@ class SettingsProvider extends NodeProvider { const children = []; if (status && status.error) children.push(errorNode(status.error)); + + const instanceHost = baseUrl.replace(/^https?:\/\//, "").replace(/:\d+$/, ""); + const aliases = [...this.store.router.aliases.entries()] + .filter(([, target]) => target === instanceHost) + .map(([alias]) => alias); + for (const alias of aliases) { + children.push( + node("hostAlias", `↳ ${alias}`, { + identifier: `settings-alias-${baseUrl}-${alias}`, + description: "host alias", + tooltip: `Git remotes on ${alias} resolve to ${baseUrl}.`, + contextValue: "hostAlias", + data: { baseUrl: baseUrl, alias: alias }, + }), + ); + } if (!hasToken) { children.push( node("action", "Set Token…", { diff --git a/Scripts/views/workflowsView.js b/Scripts/views/workflowsView.js index 6dab97b..cd6763c 100644 --- a/Scripts/views/workflowsView.js +++ b/Scripts/views/workflowsView.js @@ -17,6 +17,7 @@ class WorkflowsProvider extends NodeProvider { return [message("No token stored. Run “Gitea → Set Token…” to sign in.")]; } if (!store.repos.length) { + if (store.unmatchedHosts.length) return builders.unmatchedHostNodes(store); return [message(store.loading ? "Loading…" : "No Gitea repositories found.")]; } diff --git a/Tests/README.md b/Tests/README.md index 1f8479a..7325636 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -5,12 +5,13 @@ and a Gitea server are both stubbed, so there is nothing to install and nothing to reach over the network. ```sh -Tests/run.sh # both suites +Tests/run.sh # all suites node Tests/units.test.js +node Tests/host-aliases.test.js node Tests/integration.test.js ``` -Both exit non-zero on failure, so `Tests/run.sh` works as a CI step. +Each exits non-zero on failure, so `Tests/run.sh` works as a CI step. ## What is here @@ -20,10 +21,11 @@ Both exit non-zero on failure, so `Tests/run.sh` works as a CI step. | `fake-gitea.js` | A canned Gitea 1.27 instance served through a `fetch()` stub — three workflow runs, two pull requests, reviews with a line comment, artifacts, secrets, and variables. Records every request it answers. | | `assert.js` | The `Checker` used by both suites. | | `units.test.js` | Remote URL parsing, diff-hunk arithmetic, per-file diff extraction, time formatting, preference precedence, HTTP error messages, the pre-1.24 `/actions/tasks` fallback, pagination and query building, and the no-token path. | +| `host-aliases.test.js` | Instances answering SSH on a different hostname than the web UI: alias parsing, the unmatched case, a hand-written alias, detection from a repository's `ssh_url`, and a later repository resolving from the stored alias without another probe. | | `integration.test.js` | Activates the extension against a real throwaway git checkout, renders all four sidebar sections, and drives the commands that write files or call the API. | -`integration.test.js` creates its git fixture in a temporary directory and -removes it afterwards; nothing is written inside the repository. +`host-aliases.test.js` and `integration.test.js` create their git fixtures in a temporary directory and +remove them afterwards; nothing is written inside the repository. ## Adding a case diff --git a/Tests/fake-gitea.js b/Tests/fake-gitea.js index 81038b5..52218dd 100644 --- a/Tests/fake-gitea.js +++ b/Tests/fake-gitea.js @@ -146,7 +146,17 @@ global.fetch = async (url, init) => { if (!m) return json({ message: "no route for " + p }, 404); const rest = m[3] || ""; - if (rest === "") return json({ name: m[2], full_name: m[1] + "/" + m[2] }); + if (rest === "") { + // The instance answers SSH on a different hostname than its web UI, + // which is what host-alias detection keys off. + return json({ + name: m[2], + full_name: m[1] + "/" + m[2], + html_url: "https://gitea.test/" + m[1] + "/" + m[2], + clone_url: "https://gitea.test/" + m[1] + "/" + m[2] + ".git", + ssh_url: "git@ssh.gitea.test:" + m[1] + "/" + m[2] + ".git", + }); + } if (rest === "/branches") return json(first([{ name: "main" }, { name: "feature/login" }])); if (rest === "/actions/runs") { let runs = RUNS; diff --git a/Tests/host-aliases.test.js b/Tests/host-aliases.test.js new file mode 100644 index 0000000..0443cb3 --- /dev/null +++ b/Tests/host-aliases.test.js @@ -0,0 +1,198 @@ +// +// Instances that answer SSH on a different hostname than their web UI. +// +// The remote host is what discovery matches against, so `git@ssh.gitea.test:…` +// on an instance at `https://gitea.test` resolves to nothing until an alias +// exists — either configured by hand, or detected once from the ssh_url the +// repository advertises and then reused from preferences ever after. +// + +const { EXTENSION_ROOT } = require("./nova-stub.js"); +require("./fake-gitea.js"); +const { Checker } = require("./assert.js"); + +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); +const { execFileSync } = require("node:child_process"); + +const script = (...parts) => path.join(EXTENSION_ROOT, "Scripts", ...parts); +const t = new Checker(); + +const INSTANCE = "https://gitea.test"; +const SSH_HOST = "ssh.gitea.test"; + +const { InstanceRouter } = require(script("gitea", "router.js")); +const credentials = require(script("gitea", "credentials.js")); +const discovery = require(script("gitea", "discovery.js")); +const config = require(script("util", "config.js")); + +// -- fixtures -------------------------------------------------------------- + +const temporary = []; + +/** A throwaway workspace holding one git repository with the given remote. */ +function workspaceWith(name, remoteUrl) { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-nova-alias-")); + temporary.push(workspace); + + const repo = path.join(workspace, name); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync(path.join(repo, "README.md"), `# ${name}\n`); + + const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf-8" }); + git("init", "-q", "-b", "main"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + git("remote", "add", "origin", remoteUrl); + git("add", "-A"); + git("commit", "-qm", "initial"); + + return workspace; +} + +function cleanup() { + for (const dir of temporary) fs.rmSync(dir, { recursive: true, force: true }); +} + +/** A fresh router with the alias preference set to `entries`. */ +function routerWith(entries) { + nova.config.set("gitea.hostAliases", entries); + return new InstanceRouter(); +} + +nova.workspace.path = workspaceWith("widget", `git@${SSH_HOST}:acme/widget.git`); +nova.config.set("gitea.baseUrl", INSTANCE); + +(async () => { + t.section("alias parsing"); + for (const [label, entry] of [ + ["equals separator", `${SSH_HOST} = ${INSTANCE}`], + ["arrow separator", `${SSH_HOST} -> ${INSTANCE}`], + ["fat arrow separator", `${SSH_HOST}=>${INSTANCE}`], + ["bare host as the target", `${SSH_HOST} = gitea.test`], + ["untidy whitespace", ` ${SSH_HOST} = ${INSTANCE} `], + ["ports ignored on both sides", `${SSH_HOST}:2222 = ${INSTANCE}`], + ["case-insensitive", `${SSH_HOST.toUpperCase()} = ${INSTANCE}`], + ]) { + t.equals(label, routerWith([entry]).baseUrlForHost(SSH_HOST), INSTANCE); + } + t.equals( + "a direct host match still wins", + routerWith([`${SSH_HOST} = ${INSTANCE}`]).baseUrlForHost("gitea.test"), + INSTANCE, + ); + + t.section("alias entries that should be ignored"); + for (const [label, entry] of [ + ["blank", " "], + ["comment", `# ${SSH_HOST} = ${INSTANCE}`], + ["no separator", `${SSH_HOST} ${INSTANCE}`], + ["too many separators", `${SSH_HOST} = ${INSTANCE} = extra`], + ["self-referential", `${SSH_HOST} = ${SSH_HOST}`], + ]) { + t.equals(label, routerWith([entry]).baseUrlForHost(SSH_HOST), null); + } + t.equals( + "an unrelated host stays unmatched", + routerWith([`${SSH_HOST} = ${INSTANCE}`]).baseUrlForHost("github.com"), + null, + ); + + t.section("unmatched: no alias and nothing to ask"); + // Without a token there is no instance to interrogate, so this is the pure + // host-matching path and it must fail visibly rather than silently. + nova.config.set("gitea.hostAliases", []); + credentials.removeToken(INSTANCE); + + const bare = new InstanceRouter(); + const unmatched = await discovery.discover(bare); + t.equals("no repository resolves", unmatched.repos.length, 0); + t.equals("the unmatched host is reported", unmatched.unmatched.map((u) => u.host), [SSH_HOST]); + t.equals( + "the report carries the remote for context", + unmatched.unmatched[0]?.remoteUrl, + `git@${SSH_HOST}:acme/widget.git`, + ); + t.equals("and nothing was persisted", nova.config.get("gitea.hostAliases", "array"), []); + + t.section("matched: alias configured by hand"); + const configured = routerWith([`${SSH_HOST} = ${INSTANCE}`]); + const viaConfig = await discovery.discover(configured); + t.equals("the repository resolves", viaConfig.repos.length, 1); + t.equals("routed to the web instance", viaConfig.repos[0]?.baseUrl, INSTANCE); + t.equals("owner and name come from the remote", viaConfig.repos[0]?.fullName, "acme/widget"); + t.equals("nothing left unmatched", viaConfig.unmatched.length, 0); + + t.section("matched: alias detected from the API"); + // No alias configured, but a token is available. The instance is asked for + // the repository and identifies itself through the ssh_url it advertises. + nova.config.set("gitea.hostAliases", []); + credentials.setToken(INSTANCE, "tok_test"); + + const detecting = new InstanceRouter(); + const detected = await discovery.discover(detecting); + t.equals("the repository resolves with no configuration", detected.repos.length, 1); + t.equals("routed to the web instance", detected.repos[0]?.baseUrl, INSTANCE); + t.equals("nothing left unmatched", detected.unmatched.length, 0); + t.check("the host was probed", detecting._probedHosts.has(SSH_HOST)); + t.check( + "the alias is live on the router", + detecting.aliases.get(SSH_HOST) === "gitea.test", + detecting.aliases.get(SSH_HOST), + ); + t.check( + "and was written to preferences", + (nova.config.get("gitea.hostAliases", "array") || []).includes(`${SSH_HOST} = ${INSTANCE}`), + JSON.stringify(nova.config.get("gitea.hostAliases", "array")), + ); + + t.section("a later repository on the same host"); + // What the stored alias buys: a different repository, in a different + // workspace, resolves straight away without another round trip. + nova.workspace.path = workspaceWith("gadget", `git@${SSH_HOST}:acme/gadget.git`); + + const returning = new InstanceRouter(); + const later = await discovery.discover(returning); + t.equals("the new repository resolves", later.repos.length, 1); + t.equals("identified correctly", later.repos[0]?.fullName, "acme/gadget"); + t.equals("routed to the web instance", later.repos[0]?.baseUrl, INSTANCE); + t.equals("nothing left unmatched", later.unmatched.length, 0); + t.equals("and no probe was needed", returning._probedHosts.size, 0); + + t.section("detection is not repeated"); + // A host belonging to no instance must not be probed on every refresh. + nova.config.set("gitea.hostAliases", []); + const probing = new InstanceRouter(); + t.equals("first probe finds nothing", await probing.detectHostAlias("elsewhere.test", "a", "b"), null); + t.check("the host is remembered as probed", probing._probedHosts.has("elsewhere.test")); + t.equals("a second probe short-circuits", await probing.detectHostAlias("elsewhere.test", "a", "b"), null); + probing.invalidate(); + t.check("an explicit refresh clears the record", !probing._probedHosts.has("elsewhere.test")); + + t.section("persisting an alias"); + nova.config.set("gitea.hostAliases", []); + t.equals("a new alias is written", config.addHostAlias(SSH_HOST, INSTANCE), true); + t.equals("writing the same alias again is a no-op", config.addHostAlias(SSH_HOST, INSTANCE), false); + t.equals( + "re-pointing a host replaces rather than duplicates", + config.addHostAlias(SSH_HOST, "https://other.test"), + true, + ); + t.equals("one entry per host", (nova.config.get("gitea.hostAliases", "array") || []).length, 1); + + t.section("workspace aliases add to global ones"); + nova.config.set("gitea.hostAliases", [`${SSH_HOST} = ${INSTANCE}`]); + nova.workspace.config.set("gitea.hostAliases", [`other.test = ${INSTANCE}`]); + const merged = new InstanceRouter(); + t.equals("global entry applies", merged.baseUrlForHost(SSH_HOST), INSTANCE); + t.equals("workspace entry applies", merged.baseUrlForHost("other.test"), INSTANCE); + nova.workspace.config.remove("gitea.hostAliases"); + + cleanup(); + t.finish("HOST ALIAS CHECKS"); +})().catch((error) => { + console.error("HARNESS ERROR", error); + cleanup(); + process.exit(2); +}); diff --git a/Tests/run.sh b/Tests/run.sh index bea1b52..16b19a5 100755 --- a/Tests/run.sh +++ b/Tests/run.sh @@ -8,7 +8,7 @@ set -e cd "$(dirname "$0")" status=0 -for suite in units.test.js integration.test.js; do +for suite in units.test.js host-aliases.test.js integration.test.js; do printf '\n########## %s ##########\n' "$suite" node "$suite" || status=1 done diff --git a/Tools/make-icons.py b/Tools/make-icons.py index 2afb270..e66cb0c 100755 --- a/Tools/make-icons.py +++ b/Tools/make-icons.py @@ -7,12 +7,15 @@ metadata.json marking it a template image so macOS tints it for light and dark sidebars. The shapes are drawn as unit-square coverage tests and supersampled, which keeps the whole thing dependency-free. -Run from the extension root: python3 Tools/make-icons.py +Run from anywhere: + python3 Tools/make-icons.py regenerate + python3 Tools/make-icons.py --check verify without writing """ import math import os import struct +import sys import zlib SS = 4 # supersamples per axis @@ -143,10 +146,65 @@ ICONS = { ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -for name, (shape, sizes) in ICONS.items(): - folder = os.path.join(ROOT, "Images", name) - for index, size in enumerate(sizes): - suffix = "" if index == 0 else "@2x" - png(os.path.join(folder, f"{name}{suffix}.png"), size, size, render(size, shape)) - open(os.path.join(folder, "metadata.json"), "w").write('{\n "template": true\n}\n') - print("wrote", folder) + +def scanlines(path): + """The decompressed scanline bytes of a PNG. + + Compared instead of the file bytes because zlib's output varies between + versions, which would make a byte-for-byte check fail spuriously in CI. + """ + data = open(path, "rb").read() + idat = b"" + i = 8 + while i < len(data): + length = struct.unpack(">I", data[i:i + 4])[0] + if data[i + 4:i + 8] == b"IDAT": + idat += data[i + 8:i + 8 + length] + i += 12 + length + return zlib.decompress(idat) + + +def targets(): + for name, (shape, sizes) in ICONS.items(): + folder = os.path.join(ROOT, "Images", name) + for index, size in enumerate(sizes): + suffix = "" if index == 0 else "@2x" + yield os.path.join(folder, f"{name}{suffix}.png"), size, shape, folder + + +def write_all(): + for path, size, shape, folder in targets(): + png(path, size, size, render(size, shape)) + for name in ICONS: + folder = os.path.join(ROOT, "Images", name) + with open(os.path.join(folder, "metadata.json"), "w") as handle: + handle.write('{\n "template": true\n}\n') + print("wrote", folder) + + +def check_all(): + stale = [] + for path, size, shape, _ in targets(): + expected = bytes(b"".join( + b"\x00" + bytes(render(size, shape)[y * size * 4:(y + 1) * size * 4]) + for y in range(size) + )) + if not os.path.exists(path): + stale.append(f"{os.path.relpath(path, ROOT)} is missing") + elif scanlines(path) != expected: + stale.append(f"{os.path.relpath(path, ROOT)} differs from the source shapes") + + if stale: + for line in stale: + print("stale:", line) + print("\nRun: python3 Tools/make-icons.py") + return 1 + + print(f"all {sum(1 for _ in targets())} images match the source shapes") + return 0 + + +if __name__ == "__main__": + if "--check" in sys.argv: + raise SystemExit(check_all()) + write_all() diff --git a/extension.json b/extension.json index be0ca20..20132a5 100644 --- a/extension.json +++ b/extension.json @@ -29,6 +29,7 @@ { "separator": true }, { "title": "Add Gitea Instance…", "command": "gitea.addInstance" }, { "title": "Remove Gitea Instance…", "command": "gitea.removeInstance" }, + { "title": "Add Host Alias…", "command": "gitea.addHostAlias" }, { "title": "Open Gitea Preferences", "command": "gitea.openPreferences" }, { "separator": true }, { "title": "Change Branch Filter…", "command": "gitea.switchBranchFilter" }, @@ -63,6 +64,7 @@ { "title": "Branch Filter", "image": "branch", "tooltip": "Change branch filter", "command": "gitea.switchBranchFilter" } ], "contextCommands": [ + { "title": "Add Host Alias…", "command": "gitea.addHostAlias", "when": "viewItem == 'unmatchedHost'" }, { "title": "Open in Browser", "command": "gitea.openInBrowser", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'pr' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'review' || viewItem == 'comment'" }, { "title": "Copy URL", "command": "gitea.copyUrl", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'pr' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'review' || viewItem == 'comment'" }, { "separator": true }, @@ -96,6 +98,7 @@ { "title": "Refresh", "image": "refresh", "tooltip": "Refresh workflow runs", "command": "gitea.refreshRuns" } ], "contextCommands": [ + { "title": "Add Host Alias…", "command": "gitea.addHostAlias", "when": "viewItem == 'unmatchedHost'" }, { "title": "Open in Browser", "command": "gitea.openInBrowser", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'workflow'" }, { "title": "Copy URL", "command": "gitea.copyUrl", "when": "viewItem == 'run' || viewItem == 'job' || viewItem == 'artifact' || viewItem == 'repo' || viewItem == 'workflow'" }, { "separator": true }, @@ -121,6 +124,7 @@ { "title": "Create", "image": "__builtin.add", "tooltip": "Create a pull request", "command": "gitea.createPullRequest" } ], "contextCommands": [ + { "title": "Add Host Alias…", "command": "gitea.addHostAlias", "when": "viewItem == 'unmatchedHost'" }, { "title": "Open Pull Request Overview", "command": "gitea.openPullRequestOverview", "when": "viewItem == 'pr'" }, { "title": "Open Pull Request Diff", "command": "gitea.openPullRequestDiff", "when": "viewItem == 'pr'" }, { "title": "Open Changed File", "command": "gitea.openChangedFile", "when": "viewItem == 'prFile'" }, @@ -150,6 +154,7 @@ { "title": "Add Instance", "image": "__builtin.add", "tooltip": "Add a Gitea instance", "command": "gitea.addInstance" } ], "contextCommands": [ + { "title": "Add Host Alias…", "command": "gitea.addHostAlias", "when": "viewItem == 'unmatchedHost'" }, { "title": "Set Token…", "command": "gitea.setToken", "when": "viewItem == 'instance'" }, { "title": "Sign Out…", "command": "gitea.clearToken", "when": "viewItem == 'instance'" }, { "title": "Test Connection", "command": "gitea.testConnection", "when": "viewItem == 'instance'" }, @@ -193,6 +198,19 @@ "description": "Extra Gitea instance URLs. Repositories are routed to an instance by matching their git remote host. Tokens are stored per instance in the macOS Keychain.", "type": "stringArray" }, + { + "key": "gitea.hostAliases", + "title": "Host Aliases", + "description": "Map alternate git remote hosts onto a configured instance, one entry per line, as \"remote-host = instance URL\". Use this when your server answers SSH on a different hostname than its web UI, for example \"ssh.example.com = https://git.example.com\".", + "type": "stringArray" + }, + { + "key": "gitea.addHostAlias", + "title": "Add Host Alias", + "description": "Map a git remote host onto one of the configured instances.", + "type": "command", + "command": "gitea.addHostAlias" + }, { "key": "gitea.setToken", "title": "Sign In",