// The API this extension exports to others (spec §9, "extension API"): the // same idea as the built-in Git extension's `getAPI()`. A forge extension // such as oxygenhub-vscode consumes it instead of running oxy itself, and // registers a remote provider so this extension can offer "Open on …" // links without knowing any hub. // // Pure functions live here so they can be tested with plain Node; the // vscode-facing object is assembled in extension.ts. /** What a remote-aware extension registers. */ export interface RemoteProvider { /** A stable id, e.g. `oxygenhub`. */ readonly id: string; /** Shown in menus: "Open on OxygenHub". */ readonly label: string; /** Whether this provider understands a remote URL. */ matches(remoteUrl: string): boolean; /** A web URL for a thing in that remote, or undefined when the provider has none. */ urlFor(remoteUrl: string, target: RemoteTarget): string | undefined; } export type RemoteTarget = | { kind: "repository" } | { kind: "bookmark"; name: string } | { kind: "change"; hash: string } | { kind: "file"; reference: string; path: string; line?: number; endLine?: number } | { kind: "proposals" } | { kind: "issues" }; /** A minimal view of one repository, stable across versions. */ export interface RepositoryHandle { readonly root: string; /** `oxy status --json` as last refreshed, or undefined before the first refresh. */ readonly status: unknown; readonly bookmarks: { name: string; heads: string[]; current?: boolean }[]; readonly currentBookmark: string | undefined; /** The repository's remotes, with the CA file each names for a self-signed hub. */ remotes(): Promise<{ name: string; url: string; ca?: string }[]>; /** Run an oxy command in this repository and parse its JSON output. */ json(args: string[]): Promise; refresh(): Promise; } export interface OxygenAPI { readonly version: 1; readonly repositories: readonly RepositoryHandle[]; /** Fires with the repository whose status changed. */ readonly onDidChangeStatus: (listener: (repo: RepositoryHandle) => void) => { dispose(): void }; /** Fires when repositories are discovered or removed. */ readonly onDidChangeRepositories: (listener: () => void) => { dispose(): void }; getRepository(fsPath: string): RepositoryHandle | undefined; registerRemoteProvider(provider: RemoteProvider): { dispose(): void }; /** The providers registered so far, for callers that build their own menus. */ remoteProviders(): readonly RemoteProvider[]; } /** The provider (and remote) to use for a repository's remotes, if any. */ export function pickProvider(providers: readonly RemoteProvider[], remotes: { name: string; url: string }[]): { provider: RemoteProvider; remote: { name: string; url: string } } | undefined { // Prefer a remote called origin or earth, then any. const ordered = [...remotes].sort((a, b) => rank(a.name) - rank(b.name)); for (const remote of ordered) { for (const provider of providers) { if (provider.matches(remote.url)) { return { provider, remote }; } } } return undefined; } function rank(name: string): number { return name === "origin" ? 0 : name === "hub" ? 1 : 2; } /** The repository handle a folder path belongs to: the longest root that prefixes it. */ export function repositoryFor(roots: string[], fsPath: string): string | undefined { let best: string | undefined; for (const r of roots) { if ((fsPath === r || fsPath.startsWith(r.endsWith("/") ? r : r + "/")) && (!best || r.length > best.length)) { best = r; } } return best; } /** * A state address for permalinks, as OxygenHub writes it: the state's head * hashes, sorted and joined with `+`. Undefined without heads. */ export function stateSpec(heads: readonly string[]): string | undefined { const hex = [...new Set(heads.map((h) => h.toLowerCase()))].filter((h) => /^[0-9a-f]{64}$/.test(h)).sort(); return hex.length > 0 && hex.length === new Set(heads).size ? hex.join("+") : undefined; } /** * The 1-based lines to link: the selection when the clicked line is inside it * (or nothing was clicked), otherwise the clicked line alone. `selection` is * 1-based and inclusive; a selection ending at column 0 of a line does not * include that line. */ export function linkedLines(clicked: number | undefined, selection: { start: number; end: number; endColumn: number; empty: boolean }): { line: number; endLine?: number } { let end = selection.end; if (!selection.empty && selection.endColumn === 0 && end > selection.start) { end -= 1; } if (clicked !== undefined && (selection.empty || clicked < selection.start || clicked > end)) { return { line: clicked }; } if (selection.empty || end === selection.start) { return { line: selection.start }; } return { line: selection.start, endLine: end }; }