diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a73322fc65114..b840118550382 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2006,7 +2006,7 @@ function OpenCommandPaletteDialog(props: { function getDefaultCloneDestination( environmentId: EnvironmentId, remoteUrl: string, - repository: string, + repository?: string, ): string { const environment = environments.find((candidate) => candidate.environmentId === environmentId); return getCloneDestinationPath( @@ -2061,7 +2061,6 @@ function OpenCommandPaletteDialog(props: { const destinationPath = getDefaultCloneDestination( addProjectCloneFlow.environmentId, remoteUrl, - rawRepository, ); setAddProjectCloneFlow({ step: "confirm", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 39731cb3d35b6..15e125587cb6e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -155,7 +155,9 @@ publish it. If Git has no name or email on that machine, the project is created first commit. Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting -provider or paste a Git URL, then choose where to save it. The project opens right away while the +provider or paste a Git URL, then choose where to save it. A branch or file page copied from GitHub, +GitLab, GitCafe, Codeberg or Bitbucket also works: T3 Code clones that repository's default branch. +On other hosts, paste the repository's clone URL. The project opens right away while the clone runs in the background: you can write your first prompt, and sending waits until the files are in place. A toast tracks progress and lets you cancel; if the clone fails, retry it from the toast or from the banner above the composer. When the host refuses the machine's SSH key, T3 Code diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 5b55a40f4ebb2..698754f069ccc 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -63,6 +63,33 @@ 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"); + for (const gitlabProject of [ + "https://gitlab.com/acme/tools/src/branch/worker.git", + "https://scm.example.test/acme/tools/src/branch/worker.git", + "https://scm.example.test/acme/tools/src/branch/worker", + "https://scm.example.test/team/repo/src/branch/main/-/README.md", + "https://forgejo.example.test/a/b/src/branch/mount/team/repo.git", + ]) { + expect(normalizePastedCloneUrl(gitlabProject)).toBe(gitlabProject); + } + }); + + it("clones the repository when given a page URL copied from the browser", () => { + for (const [pageUrl, pageRoute, repository] of [ + ["https://github.com/pingdotgg/t3code/tree/main", "/tree/", "t3code"], + ["https://gitlab.com/gitlab-org/gitlab-runner/-/tree/main", "/-/", "gitlab-runner"], + ["https://git.cafe/pingdotgg/t3code/tree/main", "/tree/", "t3code"], + ["https://gitlab.example.test/g/p/-/blob/main/src/branch/x", "/-/", "p"], + ["https://code.company.test/g/p/-/tree/main", "/-/", "p"], + ["https://github.com/pingdotgg/t3code/tree/release.git", "/tree/", "t3code"], + ] as const) { + const cloneUrl = normalizePastedCloneUrl(pageUrl); + expect(cloneUrl).not.toContain(pageRoute); + expect(getCloneDirectoryName(cloneUrl)).toBe(repository); + } + expect( + normalizePastedCloneUrl("https://alice:example@bitbucket.org/workspace/repo/src/master/"), + ).toBe("https://alice:example@bitbucket.org/workspace/repo"); }); 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..f0d7e489423fd 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -128,7 +128,8 @@ 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; + return sourceControlClients.repositoryUrlFromPage(trimmed) ?? trimmed; } export function getDefaultCloneUrl( diff --git a/packages/source-control-bitbucket/src/shared/definition.test.ts b/packages/source-control-bitbucket/src/shared/definition.test.ts index f10b9c7e6bdf0..da844c2b2a2ab 100644 --- a/packages/source-control-bitbucket/src/shared/definition.test.ts +++ b/packages/source-control-bitbucket/src/shared/definition.test.ts @@ -51,6 +51,17 @@ describe("Bitbucket change request URLs", () => { }); }); +describe("Bitbucket repository pages", () => { + it("reads the repository from a source view on Bitbucket hosts only", () => { + const repositoryUrl = (url: string) => definition.repositoryUrlFromPage?.(new URL(url)); + expect(repositoryUrl("https://bitbucket.org/workspace/repo/src/main/")).toBe( + "https://bitbucket.org/workspace/repo", + ); + expect(repositoryUrl("https://bitbucket.org/workspace/repo")).toBeNull(); + expect(repositoryUrl("https://scm.example.test/workspace/repo/src/main/")).toBeNull(); + }); +}); + describe("Bitbucket remotes", () => { const recognize = (url: string) => recognizeRemoteWith(definition, url); diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index 2f643751c599a..edb1b103081cb 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -14,6 +14,7 @@ import { isChangeRequestOnProjectHost, parseChangeRequestPath, parseCommitPath, + repositoryUrlFromPagePath, projectChangeRequestHost, } 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 REPOSITORY_PAGE_PATH = /^\/([^/]+\/[^/]+)\/src\/[^/]/u; const KIND = SourceControlProviderKind.make("bitbucket"); @@ -96,6 +98,8 @@ export const definition = defineSourceControl({ commitUrl: (input) => buildCommitUrl(input, "commits"), parseCommitUrl: (url) => isBitbucketHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null, + repositoryUrlFromPage: (url) => + isBitbucketHost(url.hostname) ? repositoryUrlFromPagePath(url, REPOSITORY_PAGE_PATH) : null, changeRequestUrlFromReferenceAutolink: () => null, // No endpoint reopens a declined pull request, and nothing documented moves one in or out of // draft, so neither is offered rather than failing when pressed. diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 8f71e1cc73468..a94ed6708e2d6 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -258,6 +258,12 @@ 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 URL when `url` is a page inside one of this host's repositories. `known` is set + * when the caller already knows this host serves the URL's repository. Off a known host, return + * `url.href` when the path may already be a clone URL, so no rewrite happens. + */ + readonly repositoryUrlFromPage?: (url: URL, known: boolean) => 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 +416,18 @@ export function parseChangeRequestPath(url: URL, route: RegExp): ParsedChangeReq : null; } +/** Reads a repository page from `url` 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 the credentials and port a private clone relies on. + const repositoryUrl = new URL(url); + repositoryUrl.pathname = `/${repository}`; + repositoryUrl.search = ""; + repositoryUrl.hash = ""; + return repositoryUrl.href; +} + export function changeRequestUrlBelow(webUrl: string, route: string): string { return `${webUrl.replace(/\/+$/u, "")}/${route}`; } @@ -615,6 +633,8 @@ export interface SourceControlDefinitionRegistry { readonly hostLabelForChangeRequestUrl: (url: string) => string; readonly isSameChangeRequest: (left: string, right: string) => boolean; readonly changeRequestUrlFromReferenceAutolink: (url: string) => string | null; + /** The repository a page URL copied from a browser is in, such as a branch or file view. */ + readonly repositoryUrlFromPage: (url: string) => string | null; /** * The host a remote is on, from the first definition in recognition order that claims it, or an * unknown host. Null when the remote names no host. @@ -683,6 +703,8 @@ export function makeSourceControlDefinitionRegistry( } return undefined; }; + const remoteOwner = (remote: GitRemoteHost) => + recognitionOrder.find((definition) => definition.recognizeRemote(remote) !== null); const parseChangeRequestUrl = (value: string): ParsedChangeRequest | null => { const url = httpUrl(value); if (url === null) return null; @@ -744,6 +766,24 @@ export function makeSourceControlDefinitionRegistry( } return null; }, + repositoryUrlFromPage: (value) => { + const url = httpUrl(value); + if (url === null) return null; + // A recognized host's paths are its own, so another host's any-host route never rewrites them. + const remote = parseGitRemoteHost(value); + const owner = remote === null ? undefined : remoteOwner(remote); + if (owner) return owner.repositoryUrlFromPage?.(url, true) ?? null; + // Elsewhere a path ending in `.git` is already a clone URL, whatever routes it contains. + if (url.pathname.replace(/\/$/u, "").endsWith(".git")) return null; + // On an unrecognized host, only rewrite when every route that matches agrees. A route that + // answers with the page itself says it may already be the clone URL. + const [repositoryUrl, ...others] = new Set( + claimOrder.flatMap((definition) => definition.repositoryUrlFromPage?.(url, false) ?? []), + ); + return repositoryUrl !== undefined && repositoryUrl !== url.href && others.length === 0 + ? repositoryUrl + : null; + }, recognizeRemote: (remoteUrl) => { const remote = parseGitRemoteHost(remoteUrl); if (remote === null) return null; diff --git a/packages/source-control-forgejo/src/shared/definition.test.ts b/packages/source-control-forgejo/src/shared/definition.test.ts index 139d21c37f5ca..ccf88aa461def 100644 --- a/packages/source-control-forgejo/src/shared/definition.test.ts +++ b/packages/source-control-forgejo/src/shared/definition.test.ts @@ -104,6 +104,22 @@ describe("Forgejo change request URLs", () => { }); }); +describe("Forgejo repository pages", () => { + it("reads the repository from branch, tag and commit views", () => { + const repositoryUrl = (url: string) => definition.repositoryUrlFromPage?.(new URL(url), true); + expect(repositoryUrl("https://codeberg.org/forgejo/forgejo/src/branch/forgejo/README.md")).toBe( + "https://codeberg.org/forgejo/forgejo", + ); + expect(repositoryUrl("http://git.example.test:3000/git/team/repo/src/tag/v1.0")).toBe( + "http://git.example.test:3000/git/team/repo", + ); + expect(repositoryUrl("https://codeberg.org/forgejo/forgejo/src/commit/abc123")).toBe( + "https://codeberg.org/forgejo/forgejo", + ); + expect(repositoryUrl("https://codeberg.org/forgejo/forgejo")).toBeNull(); + }); +}); + describe("Forgejo remotes", () => { it("recognises Codeberg and Forgejo or Gitea hosts, keeping an HTTP remote's origin", () => { for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index f78805fee1c60..65360496f28d1 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -10,6 +10,7 @@ import { isChangeRequestOnProjectHost, parseChangeRequestPath, parseCommitPath, + repositoryUrlFromPagePath, projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/shared/definition"; @@ -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 REPOSITORY_PAGE_PATH = /^\/([^/]+(?:\/[^/]+)+?)\/src\/(?:branch|tag|commit)\/[^/]/u; const KIND = SourceControlProviderKind.make("forgejo"); @@ -87,6 +89,13 @@ export const definition = defineSourceControl({ changeRequestUrlFromReferenceAutolink: () => null, commitUrl: (input) => buildCommitUrl(input, "commit"), parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), + repositoryUrlFromPage: (url, known) => { + // A mount path may itself contain `src/branch/`, so a `.git` path is always the clone URL. + if (url.pathname.replace(/\/$/u, "").endsWith(".git")) return null; + const repositoryUrl = repositoryUrlFromPagePath(url, REPOSITORY_PAGE_PATH); + // Elsewhere the same path is a GitLab project nested under groups named `src` and `branch`. + return repositoryUrl === null || known ? repositoryUrl : url.href; + }, changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number. checkoutCommand: ({ number, repositoryUrl }) => diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index 3749063ce7c1b..2abc258fbf955 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -14,6 +14,7 @@ import { parseChangeRequestPath, parseCommitPath, projectChangeRequestHost, + repositoryUrlFromPagePath, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -23,6 +24,7 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); /** GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. */ const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u; const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]+)\/?$/iu; +const REPOSITORY_PAGE_PATH = /^\/([^/]+\/[^/]+)\/(?:tree|blob)\/[^/]/u; function parseChangeRequestUrl(url: URL) { return GITCAFE_HOSTS.has(url.hostname.toLowerCase()) @@ -72,6 +74,10 @@ export const definition = defineSourceControl({ commitUrl: (input) => buildCommitUrl(input, "commit"), parseCommitUrl: (url) => GITCAFE_HOSTS.has(url.hostname.toLowerCase()) ? parseCommitPath(url, COMMIT_PATH) : null, + repositoryUrlFromPage: (url) => + GITCAFE_HOSTS.has(url.hostname.toLowerCase()) + ? repositoryUrlFromPagePath(url, REPOSITORY_PAGE_PATH) + : null, changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "ready", diff --git a/packages/source-control-github/src/shared/definition.test.ts b/packages/source-control-github/src/shared/definition.test.ts index 924ab31716853..17ac17ad437b1 100644 --- a/packages/source-control-github/src/shared/definition.test.ts +++ b/packages/source-control-github/src/shared/definition.test.ts @@ -96,6 +96,14 @@ describe("GitHub change request URLs", () => { }); }); +describe("GitHub repository pages", () => { + it("leaves the tree route on other hosts to them", () => { + expect( + definition.repositoryUrlFromPage?.(new URL("https://scm.example.test/o/r/tree/main")), + ).toBeNull(); + }); +}); + describe("GitHub remotes", () => { const recognize = (url: string) => recognizeRemoteWith(definition, url); diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 3c33cfb3a3f64..b08547ae12c1b 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -8,6 +8,7 @@ import { isChangeRequestOnProjectHost, parseChangeRequestPath, parseCommitPath, + repositoryUrlFromPagePath, projectChangeRequestHost, remoteWebOrigin, type SourceControlAppSignIn, @@ -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 REPOSITORY_PAGE_PATH = /^\/([^/]+\/[^/]+)\/(?:tree|blob)\/[^/]/u; const KIND = SourceControlProviderKind.make("github"); @@ -98,6 +100,8 @@ export const definition = defineSourceControl({ parseCommitUrl: (url, known) => known || isGitHubHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + repositoryUrlFromPage: (url) => + isGitHubHost(url.hostname) ? repositoryUrlFromPagePath(url, REPOSITORY_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..96d6f7be7200f 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -7,6 +7,7 @@ import { isChangeRequestOnProjectHost, parseChangeRequestPath, parseCommitPath, + repositoryUrlFromPagePath, projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/shared/definition"; @@ -20,6 +21,8 @@ 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; +// Every GitLab page inside a project sits below its first `/-/`. +const REPOSITORY_PAGE_PATH = /^\/([^/]+(?:\/[^/]+)+?)\/-\//u; const KIND = SourceControlProviderKind.make("gitlab"); @@ -47,6 +50,7 @@ export const definition = defineSourceControl({ parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), commitUrl: (input) => buildCommitUrl(input, "-/commit"), parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), + repositoryUrlFromPage: (url) => repositoryUrlFromPagePath(url, REPOSITORY_PAGE_PATH), // `!12` already links the merge request itself; `#12` is always an issue. changeRequestUrlFromReferenceAutolink: (url) => CHANGE_REQUEST_PATH.test(url.pathname) ? url.toString() : null,