Files
paseo/packages/app/src/git/forge-url.ts
T
869edcbf11 fix(forge): preserve non-default port in forge web URLs (#2478)
"Open in browser" links for a self-hosted forge served on a non-standard
port (e.g. Forgejo/Gitea on :60443) dropped the port, producing
https://host/owner/repo/... instead of https://host:60443/owner/repo/...,
which 404s or hits the wrong service.

parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no
port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the
origin from the portless host. Preserve the port on GitRemoteLocation and
reattach it in the web-URL builders, only for self-hosted http(s) origins
(an SSH port isn't the web port; a canonicalized cloud host uses the
default port). Host-identity matching (forge detection, cloud-host checks)
stays port-agnostic.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 04:00:46 +08:00

140 lines
5.1 KiB
TypeScript

/**
* Forge-neutral web URL builders for "Open on <forge>" actions (a file blob or a
* branch tree). The host comes from the workspace remote — not a hardcoded
* cloud host — so self-hosted and Enterprise instances link correctly. Each
* forge contributes a small URL grammar (the path infixes and line-anchor
* format); an unknown forge has no grammar and yields null, so the action is
* simply absent rather than wrong.
*
* URL grammar lives on each client forge module. The repo identity and host
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}
interface ForgeWebLocation {
host: string;
/** Non-default port for a self-hosted http(s) origin, or undefined. */
port?: string;
repo: string;
}
/**
* Web host + repo path from a remote. The host is the remote's host, except a
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
* Self-hosted hosts are returned untouched. The repo path supports nested
* groups (e.g. GitLab subgroups) since it is the full remote path.
*/
function isValidRepoPath(path: string): boolean {
const segments = path.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return false;
}
return !segments.includes("..");
}
function resolveForgeWebLocation(
forge: string,
remoteUrl: string | null | undefined,
): ForgeWebLocation | null {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
const isCloudHost = cloudHosts.includes(location.host);
const webHost = isCloudHost ? cloudHosts[0] : location.host;
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
// web port, and a canonicalized cloud host always serves on the default port.
const port =
!isCloudHost && (location.transport === "http" || location.transport === "https")
? location.port
: undefined;
return { host: webHost, port, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Host, plus `:port` when the remote pins a non-default port. */
function forgeAuthority(location: ForgeWebLocation): string {
return location.port ? `${location.host}:${location.port}` : location.host;
}
function normalizeBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildForgeBranchTreeUrl(
forge: string,
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
}