From 2f388d4d5687c4b673ba376b0a4f778e3e02d09c Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:21:21 +0200 Subject: [PATCH] Get PR webview opening working when we don't have a folder repo manager --- src/api/remoteOnlyRepository.ts | 215 ++++++++++++++++++++++ src/common/externalUri.ts | 8 + src/extension.ts | 2 +- src/github/externalUriOpener.ts | 162 ++++++++++------ src/github/overviewRestorer.ts | 8 +- src/test/common/externalUri.test.ts | 20 +- src/test/github/externalUriOpener.test.ts | 62 +++++++ 7 files changed, 413 insertions(+), 64 deletions(-) create mode 100644 src/api/remoteOnlyRepository.ts create mode 100644 src/test/github/externalUriOpener.test.ts diff --git a/src/api/remoteOnlyRepository.ts b/src/api/remoteOnlyRepository.ts new file mode 100644 index 0000000000..db6d47ad97 --- /dev/null +++ b/src/api/remoteOnlyRepository.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Branch, BranchQuery, Change, Commit, CommitOptions, FetchOptions, InputBox, Ref, RefQuery, Repository, RepositoryState, RepositoryUIState } from './api'; + +export class RemoteOnlyRepository implements Repository, vscode.Disposable { + private readonly _onDidChangeState = new vscode.EventEmitter(); + private readonly _onDidChangeUiState = new vscode.EventEmitter(); + + readonly inputBox: InputBox = { value: '' }; + readonly rootUri = vscode.Uri.from({ scheme: 'github-remote', authority: 'github.com' }); + readonly state: RepositoryState = { + HEAD: undefined, + remotes: [], + submodules: [], + worktrees: undefined, + rebaseCommit: undefined, + mergeChanges: [], + indexChanges: [], + workingTreeChanges: [], + onDidChange: this._onDidChangeState.event, + }; + readonly ui: RepositoryUIState = { + selected: false, + onDidChange: this._onDidChangeUiState.event, + }; + + dispose(): void { + this._onDidChangeState.dispose(); + this._onDidChangeUiState.dispose(); + } + + getConfigs(): Promise<{ key: string; value: string }[]> { + return Promise.resolve([]); + } + + getConfig(_key: string): Promise { + return Promise.resolve(''); + } + + setConfig(_key: string, _value: string): Promise { + return this.unsupported('set Git configuration'); + } + + getGlobalConfig(_key: string): Promise { + return Promise.resolve(''); + } + + getObjectDetails(_treeish: string, _path: string): Promise<{ mode: string; object: string; size: number }> { + return this.unsupported('read Git object details'); + } + + detectObjectType(_object: string): Promise<{ mimetype: string; encoding?: string }> { + return this.unsupported('detect a Git object type'); + } + + buffer(_ref: string, _path: string): Promise { + return this.unsupported('read a Git object'); + } + + show(_ref: string, _path: string): Promise { + return this.unsupported('show a Git object'); + } + + getCommit(_ref: string): Promise { + return this.unsupported('read a Git commit'); + } + + clean(_paths: string[]): Promise { + return this.unsupported('clean files'); + } + + apply(_patch: string, _reverse?: boolean): Promise { + return this.unsupported('apply a patch'); + } + + diff(_cached?: boolean): Promise { + return this.unsupported('create a diff'); + } + + diffWithHEAD(): Promise; + diffWithHEAD(_path: string): Promise; + diffWithHEAD(_path?: string): Promise { + return this.unsupported('create a diff with HEAD'); + } + + diffWith(_ref: string): Promise; + diffWith(_ref: string, _path: string): Promise; + diffWith(_ref: string, _path?: string): Promise { + return this.unsupported('create a diff with a ref'); + } + + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(_path: string): Promise; + diffIndexWithHEAD(_path?: string): Promise { + return this.unsupported('create an index diff with HEAD'); + } + + diffIndexWith(_ref: string): Promise; + diffIndexWith(_ref: string, _path: string): Promise; + diffIndexWith(_ref: string, _path?: string): Promise { + return this.unsupported('create an index diff with a ref'); + } + + diffBlobs(_object1: string, _object2: string): Promise { + return this.unsupported('diff Git objects'); + } + + diffBetween(_ref1: string, _ref2: string): Promise; + diffBetween(_ref1: string, _ref2: string, _path: string): Promise; + diffBetween(_ref1: string, _ref2: string, _path?: string): Promise { + return this.unsupported('create a diff between refs'); + } + + hashObject(_data: string): Promise { + return this.unsupported('hash an object'); + } + + createBranch(_name: string, _checkout: boolean, _ref?: string): Promise { + return this.unsupported('create a branch'); + } + + deleteBranch(_name: string, _force?: boolean): Promise { + return this.unsupported('delete a branch'); + } + + getBranch(_name: string): Promise { + return this.unsupported('read a branch'); + } + + getBranches(_query: BranchQuery): Promise { + return Promise.resolve([]); + } + + getBranchBase(_name: string): Promise { + return Promise.resolve(undefined); + } + + setBranchUpstream(_name: string, _upstream: string): Promise { + return this.unsupported('set a branch upstream'); + } + + getRefs(_query: RefQuery, _cancellationToken?: vscode.CancellationToken): Promise { + return Promise.resolve([]); + } + + getMergeBase(_ref1: string, _ref2: string): Promise { + return Promise.resolve(undefined); + } + + status(): Promise { + return this.unsupported('read Git status'); + } + + checkout(_treeish: string): Promise { + return this.unsupported('check out a ref'); + } + + addRemote(_name: string, _url: string): Promise { + return this.unsupported('add a remote'); + } + + removeRemote(_name: string): Promise { + return this.unsupported('remove a remote'); + } + + renameRemote(_name: string, _newName: string): Promise { + return this.unsupported('rename a remote'); + } + + fetch(_options?: FetchOptions): Promise; + fetch(_remote?: string, _ref?: string, _depth?: number): Promise; + fetch(_optionsOrRemote?: FetchOptions | string, _ref?: string, _depth?: number): Promise { + return this.unsupported('fetch'); + } + + pull(_unshallow?: boolean): Promise { + return this.unsupported('pull'); + } + + push(_remoteName?: string, _branchName?: string, _setUpstream?: boolean): Promise { + return this.unsupported('push'); + } + + blame(_path: string): Promise { + return this.unsupported('blame a file'); + } + + log(_options?: { range?: string; maxEntries?: number; path?: string; sortByAuthorDate?: boolean }): Promise { + return this.unsupported('read Git history'); + } + + commit(_message: string, _opts?: CommitOptions): Promise { + return this.unsupported('commit'); + } + + add(_paths: string[]): Promise { + return this.unsupported('add files'); + } + + merge(_ref: string): Promise { + return this.unsupported('merge'); + } + + mergeAbort(): Promise { + return this.unsupported('abort a merge'); + } + + private unsupported(operation: string): Promise { + return Promise.reject(new Error(`Cannot ${operation} without a local Git repository.`)); + } +} diff --git a/src/common/externalUri.ts b/src/common/externalUri.ts index 555df4967b..daa51d425e 100644 --- a/src/common/externalUri.ts +++ b/src/common/externalUri.ts @@ -46,6 +46,14 @@ export function parseGitHubIssueOrPullRequestUri(uri: vscode.Uri): GitHubIssueOr }; } +export function getGitHubIssueOrPullRequestUriOpenerPriority( + uri: vscode.Uri, +): vscode.ExternalUriOpenerPriority { + return parseGitHubIssueOrPullRequestUri(uri) + ? vscode.ExternalUriOpenerPriority.Preferred + : vscode.ExternalUriOpenerPriority.None; +} + export function openWithDefaultExternalOpener(uri: vscode.Uri): Thenable { return vscode.env.openExternal(uri, { allowContributedOpeners: 'default' }); } diff --git a/src/extension.ts b/src/extension.ts index c9d237eadb..070312c0e8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -296,7 +296,7 @@ async function init( context.subscriptions.push(new GitLensIntegration()); - context.subscriptions.push(new OverviewRestorer(reposManager, telemetry, context.extensionUri, credentialStore)); + context.subscriptions.push(new OverviewRestorer(reposManager, telemetry, context, credentialStore)); await vscode.commands.executeCommand('setContext', 'github:initialized', true); diff --git a/src/github/externalUriOpener.ts b/src/github/externalUriOpener.ts index 767ee54765..a04fb38c69 100644 --- a/src/github/externalUriOpener.ts +++ b/src/github/externalUriOpener.ts @@ -4,74 +4,120 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { RemoteOnlyRepository } from '../api/remoteOnlyRepository'; +import { Disposable } from '../common/lifecycle'; +import { IThemeWatcher } from '../themeWatcher'; +import { CredentialStore } from './credentials'; +import { FolderRepositoryManager } from './folderRepositoryManager'; import { IssueOverviewPanel } from './issueOverview'; import { PullRequestOverviewPanel } from './pullRequestOverview'; import { RepositoriesManager } from './repositoriesManager'; -import { parseGitHubIssueOrPullRequestUri } from '../common/externalUri'; +import { GitApiImpl } from '../api/api1'; +import { getGitHubIssueOrPullRequestUriOpenerPriority, parseGitHubIssueOrPullRequestUri } from '../common/externalUri'; import { ITelemetry } from '../common/telemetry'; import { EXTENSION_ID } from '../constants'; +import { CreatePullRequestHelper } from '../view/createPullRequestHelper'; +import { ThemeData } from '../view/theme'; -export function registerGitHubIssueOrPullRequestExternalUriOpener( - extensionUri: vscode.Uri, - repositoriesManager: RepositoriesManager, - telemetry: ITelemetry, -): vscode.Disposable { - return vscode.window.registerExternalUriOpener(`${EXTENSION_ID}.issueOrPullRequest`, { - canOpenExternalUri(uri) { - if (!parseGitHubIssueOrPullRequestUri(uri)) { - return vscode.ExternalUriOpenerPriority.None; +class GitHubIssueOrPullRequestExternalUriOpener extends Disposable implements vscode.ExternalUriOpener { + private _remoteFolderRepositoryManager: FolderRepositoryManager | undefined; + + constructor( + private readonly _context: vscode.ExtensionContext, + private readonly _repositoriesManager: RepositoriesManager, + private readonly _credentialStore: CredentialStore, + private readonly _telemetry: ITelemetry, + ) { + super(); + this._register(vscode.window.registerExternalUriOpener(`${EXTENSION_ID}.issueOrPullRequest`, this, { + schemes: ['http', 'https'], + label: vscode.l10n.t('Open GitHub Issue or Pull Request'), + })); + } + + canOpenExternalUri(uri: vscode.Uri): vscode.ExternalUriOpenerPriority { + return getGitHubIssueOrPullRequestUriOpenerPriority(uri); + } + + async openExternalUri(_resolvedUri: vscode.Uri, openContext: vscode.OpenExternalUriContext, token: vscode.CancellationToken): Promise { + const identity = parseGitHubIssueOrPullRequestUri(openContext.sourceUri); + if (!identity || token.isCancellationRequested) { + return; + } + + const folderRepositoryManager = this.getFolderRepositoryManager(identity.owner, identity.repo); + if (identity.kind === 'pullRequest') { + const pullRequest = await folderRepositoryManager.resolvePullRequest(identity.owner, identity.repo, identity.number, true); + if (token.isCancellationRequested) { + return; } - return vscode.ExternalUriOpenerPriority.Preferred; - }, - async openExternalUri(_resolvedUri, openContext, token) { - const identity = parseGitHubIssueOrPullRequestUri(openContext.sourceUri); - if (!identity || token.isCancellationRequested) { + if (!pullRequest) { + await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo)); return; } - - const folderRepositoryManager = repositoriesManager.getManagerForRepository(identity.owner, identity.repo) - ?? repositoriesManager.folderManagers[0]; - if (!folderRepositoryManager) { - await vscode.window.showErrorMessage(vscode.l10n.t('Unable to open issue or pull request #{0}: no GitHub repository is available.', identity.number)); + await PullRequestOverviewPanel.createOrShow( + this._telemetry, + this._context.extensionUri, + folderRepositoryManager, + identity, + pullRequest, + ); + } else { + const issue = await folderRepositoryManager.resolveIssue(identity.owner, identity.repo, identity.number, true, true); + if (token.isCancellationRequested) { return; } - - if (identity.kind === 'pullRequest') { - const pullRequest = await folderRepositoryManager.resolvePullRequest(identity.owner, identity.repo, identity.number, true); - if (token.isCancellationRequested) { - return; - } - if (!pullRequest) { - await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find pull request #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo)); - return; - } - await PullRequestOverviewPanel.createOrShow( - telemetry, - extensionUri, - folderRepositoryManager, - identity, - pullRequest, - ); - } else { - const issue = await folderRepositoryManager.resolveIssue(identity.owner, identity.repo, identity.number, true, true); - if (token.isCancellationRequested) { - return; - } - if (!issue) { - await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find issue #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo)); - return; - } - await IssueOverviewPanel.createOrShow( - telemetry, - extensionUri, - folderRepositoryManager, - identity, - issue, - ); + if (!issue) { + await vscode.window.showErrorMessage(vscode.l10n.t('Unable to find issue #{0} in {1}/{2}.', identity.number, identity.owner, identity.repo)); + return; } - }, - }, { - schemes: ['http', 'https'], - label: vscode.l10n.t('Open GitHub Issue or Pull Request'), - }); + await IssueOverviewPanel.createOrShow( + this._telemetry, + this._context.extensionUri, + folderRepositoryManager, + identity, + issue, + ); + } + } + + private getFolderRepositoryManager(owner: string, repo: string): FolderRepositoryManager { + const existingManager = this._repositoriesManager.getManagerForRepository(owner, repo) + ?? this._repositoriesManager.folderManagers[0]; + if (existingManager) { + return existingManager; + } + if (this._remoteFolderRepositoryManager) { + return this._remoteFolderRepositoryManager; + } + + const repository = this._register(new RemoteOnlyRepository()); + const git = this._register(new GitApiImpl(this._repositoriesManager)); + const createPullRequestHelper = this._register(new CreatePullRequestHelper()); + const onDidChangeTheme = this._register(new vscode.EventEmitter()); + const themeWatcher: IThemeWatcher = { + onDidChangeTheme: onDidChangeTheme.event, + themeData: undefined, + }; + this._remoteFolderRepositoryManager = this._register(new FolderRepositoryManager( + -1, + this._context, + repository, + this._telemetry, + git, + this._credentialStore, + createPullRequestHelper, + themeWatcher, + )); + return this._remoteFolderRepositoryManager; + } +} + +export function registerGitHubIssueOrPullRequestExternalUriOpener( + context: vscode.ExtensionContext, + repositoriesManager: RepositoriesManager, + credentialStore: CredentialStore, + telemetry: ITelemetry, +): vscode.Disposable { + return new GitHubIssueOrPullRequestExternalUriOpener(context, repositoriesManager, credentialStore, telemetry); } diff --git a/src/github/overviewRestorer.ts b/src/github/overviewRestorer.ts index f5f95f6640..2ad97d6dc5 100644 --- a/src/github/overviewRestorer.ts +++ b/src/github/overviewRestorer.ts @@ -21,13 +21,13 @@ export class OverviewRestorer extends Disposable implements vscode.WebviewPanelS constructor(private readonly _repositoriesManager: RepositoriesManager, private readonly _telemetry: ITelemetry, - private readonly _extensionUri: vscode.Uri, + private readonly _context: vscode.ExtensionContext, private readonly _credentialStore: CredentialStore ) { super(); this._register(vscode.window.registerWebviewPanelSerializer(IssueOverviewPanel.viewType, this)); this._register(vscode.window.registerWebviewPanelSerializer(PullRequestOverviewPanel.viewType, this)); - this._register(registerGitHubIssueOrPullRequestExternalUriOpener(_extensionUri, _repositoriesManager, _telemetry)); + this._register(registerGitHubIssueOrPullRequestExternalUriOpener(_context, _repositoriesManager, _credentialStore, _telemetry)); } async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: PullRequest): Promise { @@ -67,14 +67,14 @@ export class OverviewRestorer extends Disposable implements vscode.WebviewPanelS webviewPanel.dispose(); return; } - return IssueOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, issueModel, undefined, true, webviewPanel); + return IssueOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, identity, issueModel, undefined, true, webviewPanel); } else { const pullRequestModel = await repo.getPullRequest(state.number, 'OverviewRestorer.deserializeWebviewPanel', true); if (!pullRequestModel) { webviewPanel.dispose(); return; } - return PullRequestOverviewPanel.createOrShow(this._telemetry, this._extensionUri, folderManager, identity, pullRequestModel, undefined, true, webviewPanel); + return PullRequestOverviewPanel.createOrShow(this._telemetry, this._context.extensionUri, folderManager, identity, pullRequestModel, undefined, true, webviewPanel); } } diff --git a/src/test/common/externalUri.test.ts b/src/test/common/externalUri.test.ts index 9e4869fea1..fe7c01d742 100644 --- a/src/test/common/externalUri.test.ts +++ b/src/test/common/externalUri.test.ts @@ -5,7 +5,7 @@ import { default as assert } from 'assert'; import * as vscode from 'vscode'; -import { parseGitHubIssueOrPullRequestUri } from '../../common/externalUri'; +import { getGitHubIssueOrPullRequestUriOpenerPriority, parseGitHubIssueOrPullRequestUri } from '../../common/externalUri'; describe('externalUri', () => { describe('parseGitHubIssueOrPullRequestUri', () => { @@ -69,4 +69,22 @@ describe('externalUri', () => { }); } }); + + describe('getGitHubIssueOrPullRequestUriOpenerPriority', () => { + const pullRequestUri = vscode.Uri.parse('https://github.com/microsoft/vscode/pull/123'); + + it('is preferred for supported URLs', () => { + assert.strictEqual( + getGitHubIssueOrPullRequestUriOpenerPriority(pullRequestUri), + vscode.ExternalUriOpenerPriority.Preferred, + ); + }); + + it('is disabled for unsupported URLs', () => { + assert.strictEqual( + getGitHubIssueOrPullRequestUriOpenerPriority(vscode.Uri.parse('https://github.com/microsoft/vscode')), + vscode.ExternalUriOpenerPriority.None, + ); + }); + }); }); diff --git a/src/test/github/externalUriOpener.test.ts b/src/test/github/externalUriOpener.test.ts new file mode 100644 index 0000000000..7cd2f6496a --- /dev/null +++ b/src/test/github/externalUriOpener.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as vscode from 'vscode'; +import { RemoteOnlyRepository } from '../../api/remoteOnlyRepository'; +import { CredentialStore } from '../../github/credentials'; +import { registerGitHubIssueOrPullRequestExternalUriOpener } from '../../github/externalUriOpener'; +import { FolderRepositoryManager } from '../../github/folderRepositoryManager'; +import { RepositoriesManager } from '../../github/repositoriesManager'; +import { MockExtensionContext } from '../mocks/mockExtensionContext'; +import { MockTelemetry } from '../mocks/mockTelemetry'; + +describe('GitHubIssueOrPullRequestExternalUriOpener', () => { + let sandbox: SinonSandbox; + + beforeEach(() => { + sandbox = createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('creates a remote-only folder manager when no local manager is available', async () => { + const context = new MockExtensionContext(); + const telemetry = new MockTelemetry(); + const credentialStore = new CredentialStore(telemetry, context); + const repositoriesManager = new RepositoriesManager(credentialStore, telemetry); + let opener: vscode.ExternalUriOpener | undefined; + sandbox.stub(vscode.window, 'registerExternalUriOpener').callsFake((_id, value) => { + opener = value; + return new vscode.Disposable(() => undefined); + }); + const resolveIssue = sandbox.stub(FolderRepositoryManager.prototype, 'resolveIssue').callsFake(async function (this: FolderRepositoryManager) { + assert.ok(this.repository instanceof RemoteOnlyRepository); + return undefined; + }); + sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + + const registration = registerGitHubIssueOrPullRequestExternalUriOpener( + context, + repositoriesManager, + credentialStore, + telemetry, + ); + const uri = vscode.Uri.parse('https://github.com/microsoft/vscode/issues/1'); + assert.ok(opener); + const cancellation = new vscode.CancellationTokenSource(); + await opener.openExternalUri(uri, { sourceUri: uri }, cancellation.token); + cancellation.dispose(); + + assert.strictEqual(repositoriesManager.folderManagers.length, 0); + assert.strictEqual(resolveIssue.callCount, 1); + registration.dispose(); + repositoriesManager.dispose(); + credentialStore.dispose(); + }); +});