diff --git a/src/commands.ts b/src/commands.ts index 6e00eebd8c..e67fe8a32b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -33,7 +33,7 @@ import { PullRequestModel } from './github/pullRequestModel'; import { PullRequestOverviewPanel } from './github/pullRequestOverview'; import { chooseItem } from './github/quickPicks'; import { RepositoriesManager } from './github/repositoriesManager'; -import { codespacesPrLink, getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink } from './github/utils'; +import { codespacesPrLink, getIssuesUrl, getPullsUrl, isInCodespaces, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, vscodeDevPrLink, vscodeDevPrLinkFromUrl } from './github/utils'; import { BaseContext, OverviewContext } from './github/views'; import { checkoutPRInWorktree } from './github/worktree'; import { IssueChatContextItem } from './lm/issueContextProvider'; @@ -1915,6 +1915,9 @@ ${contents} context.subscriptions.push( vscode.commands.registerCommand('pr.copyVscodeDevPrLink', async (params: BaseContext | undefined) => { + if (params?.url) { + return vscode.env.clipboard.writeText(vscodeDevPrLinkFromUrl(params.url)); + } let pr: PullRequestModel | undefined; if (params) { pr = await reposManager.getManagerForRepository(params.owner, params.repo)?.resolvePullRequest(params.owner, params.repo, params.number, true); @@ -1935,6 +1938,9 @@ ${contents} context.subscriptions.push( vscode.commands.registerCommand('pr.copyPrLink', async (params: BaseContext | undefined) => { + if (params?.url) { + return vscode.env.clipboard.writeText(params.url); + } let item: PullRequestModel | IssueModel | undefined; if (params) { const folderManager = reposManager.getManagerForRepository(params.owner, params.repo); diff --git a/src/extension.ts b/src/extension.ts index cbf9ebdcc7..aca5026bd2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -49,7 +49,7 @@ import { CompareChanges } from './view/compareChangesTreeDataProvider'; import { CreatePullRequestHelper } from './view/createPullRequestHelper'; import { EmojiCompletionProvider } from './view/emojiCompletionProvider'; import { FileTypeDecorationProvider } from './view/fileTypeDecorationProvider'; -import { GitHubCommitFileSystemProvider } from './view/githubFileContentProvider'; +import { getGitHubCommitFileSystemProvider } from './view/githubFileContentProvider'; import { getInMemPRFileSystemProvider } from './view/inMemPRContentProvider'; import { PullRequestChangesTreeDataProvider } from './view/prChangesTreeDataProvider'; import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider'; @@ -495,7 +495,7 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll const readOnlyMessage = new vscode.MarkdownString(vscode.l10n.t('Cannot edit this pull request file. [Check out](command:pr.checkoutFromReadonlyFile) this pull request to edit.')); readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] }; context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage })); - const githubFilesystemProvider = new GitHubCommitFileSystemProvider(reposManager, apiImpl, credentialStore); + const githubFilesystemProvider = getGitHubCommitFileSystemProvider({ reposManager, gitAPI: apiImpl, credentialStore })!; context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.GitHubCommit, githubFilesystemProvider, { isReadonly: new vscode.MarkdownString(vscode.l10n.t('GitHub commits cannot be edited')) })); context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(Schemes.CheckRunLog, new CheckRunLogContentProvider(reposManager))); diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index 55db169021..5c12e7ab8f 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -44,6 +44,7 @@ import { toOpenIssueWebviewUri } from '../common/uri'; import { asPromise, formatError } from '../common/utils'; import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { toCheckRunLogUri } from '../view/checkRunLogContentProvider'; +import { getGitHubCommitFileSystemProvider } from '../view/githubFileContentProvider'; export class PullRequestOverviewPanel extends IssueOverviewPanel { public static override ID: string = 'PullRequestOverviewPanel'; @@ -928,11 +929,17 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { try { const { commitSha } = message.args; + const fileSystemProvider = getGitHubCommitFileSystemProvider(); + if (!fileSystemProvider) { + throw new Error('GitHub commit file system provider is not initialized.'); + } + fileSystemProvider.registerGitHubRepository(this._item.githubRepository); await PullRequestModel.openCommitChanges(this._extensionUri, this._item.githubRepository, commitSha); - this._replyMessage(message, {}); + await this._replyMessage(message, {}); } catch (error) { Logger.error(`Failed to open commit changes: ${formatError(error)}`, PullRequestOverviewPanel.ID); vscode.window.showErrorMessage(vscode.l10n.t('Failed to open commit changes: {0}', formatError(error))); + await this._throwError(message, formatError(error)); } } diff --git a/src/github/utils.ts b/src/github/utils.ts index 16a4c5bd31..4939d542f9 100644 --- a/src/github/utils.ts +++ b/src/github/utils.ts @@ -1974,7 +1974,11 @@ export async function findDotComAndEnterpriseRemotes(folderManagers: FolderRepos } export function vscodeDevPrLink(pullRequest: IssueModel) { - const itemUri = vscode.Uri.parse(pullRequest.html_url); + return vscodeDevPrLinkFromUrl(pullRequest.html_url); +} + +export function vscodeDevPrLinkFromUrl(url: string) { + const itemUri = vscode.Uri.parse(url); return `https://${vscode.env.appName.toLowerCase().includes('insider') ? 'insiders.' : ''}vscode.dev/github${itemUri.path}`; } diff --git a/src/github/views.ts b/src/github/views.ts index 3d8d213c3c..0b40604f74 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -219,7 +219,8 @@ export interface BaseContext { owner: string; repo: string; number: number; - [key: string]: boolean | string | number; + url?: string; + [key: string]: boolean | string | number | undefined; } export interface OverviewContext extends BaseContext { diff --git a/src/test/view/githubFileContentProvider.test.ts b/src/test/view/githubFileContentProvider.test.ts new file mode 100644 index 0000000000..1632669a45 --- /dev/null +++ b/src/test/view/githubFileContentProvider.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { default as assert } from 'assert'; +import { createSandbox, SinonSandbox } from 'sinon'; +import { GitApiImpl } from '../../api/api1'; +import { toGitHubCommitUri } from '../../common/uri'; +import { CredentialStore } from '../../github/credentials'; +import { GitHubRepository } from '../../github/githubRepository'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { GitHubCommitFileSystemProvider } from '../../view/githubFileContentProvider'; + +describe('GitHubCommitFileSystemProvider', function () { + let sandbox: SinonSandbox; + + beforeEach(function () { + sandbox = createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('reads from a registered repository without a workspace repository manager', async function () { + const credentialStore = { + isAnyAuthenticated: () => true, + } as unknown as CredentialStore; + const provider = new GitHubCommitFileSystemProvider( + {} as RepositoriesManager, + {} as GitApiImpl, + credentialStore, + ); + const content = new TextEncoder().encode('content'); + const getFile = sandbox.stub().resolves(content); + const repository = { + remote: { + owner: 'Owner', + repositoryName: 'Repo', + }, + getFile, + } as unknown as GitHubRepository; + provider.registerGitHubRepository(repository); + + const result = await provider.readFile(toGitHubCommitUri('file.ts', { + commit: 'commit', + owner: 'owner', + repo: 'repo', + })); + + assert.deepStrictEqual(result, content); + assert.strictEqual(getFile.calledOnceWithExactly('/file.ts', 'commit'), true); + }); +}); diff --git a/src/view/githubFileContentProvider.ts b/src/view/githubFileContentProvider.ts index 059f113840..d4566e8ffc 100644 --- a/src/view/githubFileContentProvider.ts +++ b/src/view/githubFileContentProvider.ts @@ -8,22 +8,34 @@ import { RepositoryFileSystemProvider } from './repositoryFileSystemProvider'; import { GitApiImpl } from '../api/api1'; import { fromGitHubCommitUri } from '../common/uri'; import { CredentialStore } from '../github/credentials'; +import { GitHubRepository } from '../github/githubRepository'; import { RepositoriesManager } from '../github/repositoriesManager'; export class GitHubCommitFileSystemProvider extends RepositoryFileSystemProvider { + private readonly _gitHubRepositories = new Map(); + constructor(private readonly repos: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore) { super(gitAPI, credentialStore); } + registerGitHubRepository(repository: GitHubRepository): void { + this._gitHubRepositories.set(this.repositoryKey(repository.remote.owner, repository.remote.repositoryName), repository); + } + override async readFile(uri: vscode.Uri): Promise { await this.waitForAuth(); - await this.waitForAnyGitHubRepos(this.repos); const params = fromGitHubCommitUri(uri); if (!params) { throw new Error(`Invalid GitHub commit URI: ${uri.toString()}`); } + const registeredRepository = this._gitHubRepositories.get(this.repositoryKey(params.owner, params.repo)); + if (registeredRepository) { + return registeredRepository.getFile(uri.path, params.commit); + } + + await this.waitForAnyGitHubRepos(this.repos); const folderManager = this.repos.getManagerForRepository(params.owner, params.repo); if (!folderManager) { throw new Error(`Repository not found for owner: ${params.owner}, repo: ${params.repo}`); @@ -36,4 +48,17 @@ export class GitHubCommitFileSystemProvider extends RepositoryFileSystemProvider return githubRepo.getFile(uri.path, params.commit); } + + private repositoryKey(owner: string, repo: string): string { + return `${owner.toLowerCase()}/${repo.toLowerCase()}`; + } +} + +let githubCommitFileSystemProvider: GitHubCommitFileSystemProvider | undefined; + +export function getGitHubCommitFileSystemProvider(initialize?: { reposManager: RepositoriesManager, gitAPI: GitApiImpl, credentialStore: CredentialStore }): GitHubCommitFileSystemProvider | undefined { + if (!githubCommitFileSystemProvider && initialize) { + githubCommitFileSystemProvider = new GitHubCommitFileSystemProvider(initialize.reposManager, initialize.gitAPI, initialize.credentialStore); + } + return githubCommitFileSystemProvider; } \ No newline at end of file diff --git a/webviews/components/header.tsx b/webviews/components/header.tsx index 8fc8592f6d..bd61fe0ecb 100644 --- a/webviews/components/header.tsx +++ b/webviews/components/header.tsx @@ -84,7 +84,7 @@ interface TitleProps { } function Title({ title, titleHTML, number, url, inEditMode, setEditMode, setCurrentTitle, canEdit, owner, repo }: TitleProps): JSX.Element { - const { setTitle, copyPrLink } = useContext(PullRequestContext); + const { setTitle, copyPrLink, openOnGitHub } = useContext(PullRequestContext); const titleForm = (
{' '} - + { + event.preventDefault(); + void openOnGitHub(); + }} + > #{number} diff --git a/webviews/components/stickyHeader.tsx b/webviews/components/stickyHeader.tsx index a459720522..bead62d6fa 100644 --- a/webviews/components/stickyHeader.tsx +++ b/webviews/components/stickyHeader.tsx @@ -32,7 +32,7 @@ export function useStickyHeader(titleRef: React.RefObject export function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolean }): JSX.Element { const { text, color, icon } = getStatus(pr.state, !!pr.isDraft, pr.isIssue, pr.stateReason); - const { copyPrLink } = React.useContext(PullRequestContext); + const { copyPrLink, openOnGitHub } = React.useContext(PullRequestContext); const stickyRef = React.useCallback((node: HTMLDivElement | null) => { if (node) { @@ -64,6 +64,10 @@ export function StickyHeader({ pr, visible }: { pr: PullRequest; visible: boolea number: pr.number, 'github:copyMenu': true, })} + onClick={event => { + event.preventDefault(); + void openOnGitHub(); + }} > #{pr.number} diff --git a/webviews/editorWebview/test/overview.test.tsx b/webviews/editorWebview/test/overview.test.tsx index 57c6c5b5b3..2b14eac345 100644 --- a/webviews/editorWebview/test/overview.test.tsx +++ b/webviews/editorWebview/test/overview.test.tsx @@ -5,7 +5,7 @@ import { default as assert } from 'assert'; import * as React from 'react'; -import { cleanup, render } from 'react-testing-library'; +import { cleanup, fireEvent, render } from 'react-testing-library'; import { createSandbox, SinonSandbox } from 'sinon'; import { PRContext, default as PullRequestContext } from '../../common/context'; @@ -38,6 +38,27 @@ describe('Overview', function () { assert(out.container.querySelector('.overview-title')); }); + it('opens PR number links on GitHub', function () { + const pr = new PullRequestBuilder().build(); + const context = new PRContext(pr); + const openOnGitHub = sinon.stub(context, 'openOnGitHub'); + + const out = render( + + + , + ); + + const numberLinks = out.container.querySelectorAll('.overview-title a, .sticky-header-number'); + assert.strictEqual(numberLinks.length, 2); + numberLinks.forEach(link => { + const contextData = JSON.parse(link.getAttribute('data-vscode-context')!); + assert.strictEqual(contextData.url, pr.url); + fireEvent.click(link); + }); + assert.strictEqual(openOnGitHub.callCount, 2); + }); + it('applies sticky class when scrolled', function () { const pr = new PullRequestBuilder().build(); const context = new PRContext(pr);