diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 7c0821678e512..d5d78c8cdab9c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1966,7 +1966,7 @@ function OpenCommandPaletteDialog(props: { function getDefaultCloneDestination( environmentId: EnvironmentId, remoteUrl: string, - repository: string, + repository = remoteUrl, ): string { const environment = environments.find((candidate) => candidate.environmentId === environmentId); return getCloneDestinationPath( @@ -2021,7 +2021,6 @@ function OpenCommandPaletteDialog(props: { const destinationPath = getDefaultCloneDestination( addProjectCloneFlow.environmentId, remoteUrl, - rawRepository, ); setAddProjectCloneFlow({ step: "confirm", diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 5b55a40f4ebb2..c4d4f412a58e2 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -63,6 +63,39 @@ describe("add project shared logic", () => { ); expect(normalizePastedCloneUrl("group/subgroup/project")).toBe("group/subgroup/project"); expect(normalizePastedCloneUrl("/srv/git/repo.git")).toBe("/srv/git/repo.git"); + expect(normalizePastedCloneUrl("https://gitlab.example.com/group/tree/main")).toBe( + "https://gitlab.example.com/group/tree/main", + ); + expect(normalizePastedCloneUrl("https://gitlab.com/team/platform/src/branch/repo.git")).toBe( + "https://gitlab.com/team/platform/src/branch/repo.git", + ); + expect( + normalizePastedCloneUrl("https://gitlab.example.com/team/platform/src/branch/repo.git"), + ).toBe("https://gitlab.example.com/team/platform/src/branch/repo.git"); + expect( + normalizePastedCloneUrl("https://git.example.com/team/platform/src/branch/repo.git"), + ).toBe("https://git.example.com/team/platform/src/branch/repo.git"); + }); + + it.each([ + ["https://github.com/owner/repo/tree/main", "https://github.com/owner/repo"], + ["https://github.com/owner/repo/tree/feature/x/src", "https://github.com/owner/repo"], + ["https://github.com/owner/repo/blob/main/README.md", "https://github.com/owner/repo"], + ["https://github.example.com/owner/repo/tree/main", "https://github.example.com/owner/repo"], + ["https://gitlab.com/group/sub/project/-/tree/main", "https://gitlab.com/group/sub/project"], + [ + "https://gitlab.example.com/group/project/-/blob/main/x", + "https://gitlab.example.com/group/project", + ], + ["https://codeberg.org/owner/repo/src/branch/main", "https://codeberg.org/owner/repo"], + ["https://codeberg.org/owner/repo/src/tag/v1/docs", "https://codeberg.org/owner/repo"], + ["https://bitbucket.org/workspace/repo/src/master/", "https://bitbucket.org/workspace/repo"], + ["https://token@github.com/owner/repo/tree/main?x=1#L2", "https://token@github.com/owner/repo"], + ])("clones the repository a pasted page belongs to: %s", (pageUrl, repositoryUrl) => { + expect(normalizePastedCloneUrl(pageUrl)).toBe(repositoryUrl); + expect(getCloneDirectoryName(normalizePastedCloneUrl(pageUrl))).toBe( + repositoryUrl.split("/").at(-1), + ); }); it("uses HTTPS for repositories selected through a provider", () => { diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index aab4f0f284290..6e0a81ff17ca9 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -128,7 +128,9 @@ export function githubIncompleteOwnerRepositoryMessage( export function normalizePastedCloneUrl(input: string): string { const trimmed = input.trim(); - return REPOSITORY_SHORTHAND.test(trimmed) ? (defaultHostCloneUrl(trimmed) ?? trimmed) : trimmed; + if (REPOSITORY_SHORTHAND.test(trimmed)) return defaultHostCloneUrl(trimmed) ?? trimmed; + // A URL copied from the browser is often a page inside the repository, which git can't clone. + return sourceControlClients.repositoryUrlFromPageUrl(trimmed) ?? trimmed; } export function getDefaultCloneUrl( diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index 2f643751c599a..c9b22766e2f4b 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -15,6 +15,7 @@ import { parseChangeRequestPath, parseCommitPath, projectChangeRequestHost, + repositoryUrlFromPagePath, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -23,6 +24,7 @@ const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u; const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/commits\/([0-9a-f]+)\/?$/iu; +const PAGE_PATH = /^\/([^/]+\/[^/]+)\/src\//u; const KIND = SourceControlProviderKind.make("bitbucket"); @@ -93,6 +95,8 @@ export const definition = defineSourceControl({ : `https://${host}/${repository}/pull-requests/${number}`, parseChangeRequestUrl: (url) => isBitbucketHost(url.hostname) ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) : null, + repositoryUrlFromPageUrl: (url) => + isBitbucketHost(url.hostname) ? repositoryUrlFromPagePath(url, PAGE_PATH) : null, commitUrl: (input) => buildCommitUrl(input, "commits"), parseCommitUrl: (url) => isBitbucketHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null, diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 8f71e1cc73468..c62e8df184e70 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -258,6 +258,8 @@ export interface SourceControlDefinition { readonly parseCommitUrl?: (url: URL, known: boolean) => CommitLink | null; /** Null when the URL is not one of this host's change requests. */ readonly parseChangeRequestUrl: (url: URL) => ParsedChangeRequestUrl | null; + /** The repository's URL when `url` is a page inside it, such as a branch's file tree. */ + readonly repositoryUrlFromPageUrl?: (url: URL) => string | null; /** * The change request URL a `reference` autolink might name, such as a GitHub `#123` written * through the issue route. Only a candidate: the caller must read it as a change request @@ -410,6 +412,18 @@ export function parseChangeRequestPath(url: URL, route: RegExp): ParsedChangeReq : null; } +/** Reads the repository `url` is a page of with `route`, whose first group is the repository path. */ +export function repositoryUrlFromPagePath(url: URL, route: RegExp): string | null { + const repository = route.exec(url.pathname)?.[1]; + if (!repository) return null; + // Keeps any credentials in the pasted URL, which a clone still needs. + const repositoryUrl = new URL(url); + repositoryUrl.pathname = `/${repository}`; + repositoryUrl.search = ""; + repositoryUrl.hash = ""; + return repositoryUrl.toString(); +} + export function changeRequestUrlBelow(webUrl: string, route: string): string { return `${webUrl.replace(/\/+$/u, "")}/${route}`; } @@ -611,6 +625,8 @@ export interface SourceControlDefinitionRegistry { /** The first host, in registry order, that reads `url` as one of its change requests. */ readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; readonly parseCommitUrl: (url: string, kind?: SourceControlProviderKind) => ParsedCommit | null; + /** The first host, in registry order, that reads `url` as a page inside a repository. */ + readonly repositoryUrlFromPageUrl: (url: string) => string | null; readonly findByChangeRequestUrl: (url: string) => SourceControlDefinition | undefined; readonly hostLabelForChangeRequestUrl: (url: string) => string; readonly isSameChangeRequest: (left: string, right: string) => boolean; @@ -692,6 +708,14 @@ export function makeSourceControlDefinitionRegistry( } return null; }; + const findByPublicDomain = (hostname: string) => { + const host = hostname.toLowerCase(); + return definitions.find( + (definition) => + definition.publicHost !== null && + (host === definition.publicHost || host.endsWith(`.${definition.publicHost}`)), + ); + }; return { definitions, get: (kind) => @@ -701,14 +725,7 @@ export function makeSourceControlDefinitionRegistry( const host = hostname.toLowerCase(); return definitions.find((definition) => definition.publicHost === host); }, - findByPublicDomain: (hostname) => { - const host = hostname.toLowerCase(); - return definitions.find( - (definition) => - definition.publicHost !== null && - (host === definition.publicHost || host.endsWith(`.${definition.publicHost}`)), - ); - }, + findByPublicDomain, parseChangeRequestUrl, parseCommitUrl: (value, kind) => { const url = httpUrl(value); @@ -720,6 +737,15 @@ export function makeSourceControlDefinitionRegistry( } return null; }, + repositoryUrlFromPageUrl: (value) => { + const url = httpUrl(value); + if (url === null) return null; + // Page routes can also be valid repository paths on another provider. + const owner = + findByPublicDomain(url.hostname) ?? + recognitionOrder.find((definition) => recognizeRemoteWith(definition, value) !== null); + return owner?.repositoryUrlFromPageUrl?.(url) ?? null; + }, findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition.label ?? "the host", diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index f78805fee1c60..eb57f03af06c1 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -12,6 +12,7 @@ import { parseCommitPath, projectChangeRequestHost, remoteWebOrigin, + repositoryUrlFromPagePath, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -22,6 +23,7 @@ const CHANGE_REQUEST_REFERENCE = // Forgejo and Gitea run on any hostname, possibly mounted below a path. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; const COMMIT_PATH = /^\/([^/]+(?:\/[^/]+)+)\/commit\/([0-9a-f]+)\/?$/iu; +const PAGE_PATH = /^\/([^/]+(?:\/[^/]+)+?)\/src\/(?:branch|tag|commit)\//u; const KIND = SourceControlProviderKind.make("forgejo"); @@ -84,6 +86,7 @@ export const definition = defineSourceControl({ const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); return link === null ? null : { ...link, authority: url.host.toLowerCase() }; }, + repositoryUrlFromPageUrl: (url) => repositoryUrlFromPagePath(url, PAGE_PATH), changeRequestUrlFromReferenceAutolink: () => null, commitUrl: (input) => buildCommitUrl(input, "commit"), parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 3c33cfb3a3f64..9edfece4f513c 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -9,6 +9,7 @@ import { parseChangeRequestPath, parseCommitPath, projectChangeRequestHost, + repositoryUrlFromPagePath, remoteWebOrigin, type SourceControlAppSignIn, } from "@t3tools/source-control-core/shared/definition"; @@ -26,6 +27,7 @@ const CHANGE_REQUEST_REFERENCE = const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u; const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/(?:commit|pull\/\d+\/commits)\/([0-9a-f]+)\/?$/iu; const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u; +const PAGE_PATH = /^\/([^/]+\/[^/]+)\/(?:tree|blob)\/./u; const KIND = SourceControlProviderKind.make("github"); @@ -98,6 +100,9 @@ export const definition = defineSourceControl({ parseCommitUrl: (url, known) => known || isGitHubHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + // `tree` and `blob` are also ordinary group names on other hosts, so only GitHub's hosts read them. + repositoryUrlFromPageUrl: (url) => + isGitHubHost(url.hostname) ? repositoryUrlFromPagePath(url, PAGE_PATH) : null, // GitHub writes every bare reference through `/issues/`, including pull requests. changeRequestUrlFromReferenceAutolink: (url) => { const match = isGitHubHost(url.hostname) ? ISSUE_PATH.exec(url.pathname) : null; diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 16d5bb8cf0efa..c9a5220c916ba 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -8,6 +8,7 @@ import { parseChangeRequestPath, parseCommitPath, projectChangeRequestHost, + repositoryUrlFromPagePath, remoteWebOrigin, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -20,6 +21,7 @@ const CHANGE_REQUEST_REFERENCE = // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; const COMMIT_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/commit\/([0-9a-f]+)\/?$/iu; +const PAGE_PATH = /^\/([^/]+(?:\/[^/]+)+?)\/-\//u; const KIND = SourceControlProviderKind.make("gitlab"); @@ -45,6 +47,7 @@ export const definition = defineSourceControl({ ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) : `${remoteWebOrigin(host, remoteUrl)}/${repository}/-/merge_requests/${number}`, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + repositoryUrlFromPageUrl: (url) => repositoryUrlFromPagePath(url, PAGE_PATH), commitUrl: (input) => buildCommitUrl(input, "-/commit"), parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), // `!12` already links the merge request itself; `#12` is always an issue.