diff --git a/src/vs/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts index d82651f93be..84601bcf48e 100644 --- a/src/vs/platform/opener/common/opener.ts +++ b/src/vs/platform/opener/common/opener.ts @@ -11,7 +11,7 @@ import { IEditorOptions } from 'vs/platform/editor/common/editor'; export const IOpenerService = createDecorator('openerService'); -type OpenInternalOptions = { +export type OpenInternalOptions = { /** * Signals that the intent is to open an editor to the side @@ -31,7 +31,7 @@ type OpenInternalOptions = { readonly fromUserGesture?: boolean; }; -type OpenExternalOptions = { readonly openExternal?: boolean; readonly allowTunneling?: boolean }; +export type OpenExternalOptions = { readonly openExternal?: boolean; readonly allowTunneling?: boolean }; export type OpenOptions = OpenInternalOptions & OpenExternalOptions; diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 2a960c7821b..f40d195d03b 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Command } from 'vscode'; + /** * This is the place for API experiments and proposals. * These API are NOT stable and subject to change. They are only available in the Insiders @@ -2297,4 +2299,48 @@ declare module 'vscode' { location?: Location; } //#endregion + + //#region Opener service (https://github.com/microsoft/vscode/issues/109277) + + /** + * Handles opening external uris. + * + * An extension can use this to open a `http` link to a webserver inside of VS Code instead of + * having the link be opened by the webbrowser. + * + * Currently openers may only be registered for `http` and `https` uris. + */ + export interface ExternalUriOpener { + + /** + * Try to open a given uri. + * + * @param uri The uri being opened. + * @param ctx Additional metadata about how the open was triggered. + * @param token Cancellation token. + * + * @return Optional command that opens the uri. If no command is returned, VS Code will + * continue checking to see if any other openers are available. + * + * If multiple openers are available for a given uri, then the `Command.title` is shown in the UI. + */ + openExternalUri(uri: Uri, ctx: {}, token: CancellationToken): ProviderResult; + } + + namespace window { + /** + * Register a new `ExternalUriOpener`. + * + * When a uri is about to be opened, a `onUriOpen:SCHEME` activation event is fired. + * + * @param schemes List of uri schemes the opener is triggered for. Currently only `http` + * and `https` are supported. + * @param opener Opener to register. + * + * @returns Disposable that unregisters the opener. + */ + export function registerExternalUriOpener(schemes: readonly string[], opener: ExternalUriOpener,): Disposable; + } + + //#endregion } diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 3b4c8a66c27..a69945ffec0 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -54,6 +54,7 @@ import './mainThreadTheming'; import './mainThreadTreeViews'; import './mainThreadDownloadService'; import './mainThreadUrls'; +import './mainThreadUriOpeners'; import './mainThreadWindow'; import './mainThreadWebviewManager'; import './mainThreadWorkspace'; diff --git a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts new file mode 100644 index 00000000000..c15a654f74d --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Schemas } from 'vs/base/common/network'; +import { URI } from 'vs/base/common/uri'; +import { IOpener, IOpenerService, OpenExternalOptions, OpenInternalOptions } from 'vs/platform/opener/common/opener'; +import { ExtHostContext, ExtHostUriOpenersShape, IExtHostContext, MainContext, MainThreadUriOpenersShape } from 'vs/workbench/api/common/extHost.protocol'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { extHostNamedCustomer } from '../common/extHostCustomers'; + + +@extHostNamedCustomer(MainContext.MainThreadUriOpeners) +export class MainThreadUriOpeners implements MainThreadUriOpenersShape, IOpener { + + private readonly proxy: ExtHostUriOpenersShape; + private readonly handlers = new Set(); + + constructor( + context: IExtHostContext, + @IOpenerService private readonly openerService: IOpenerService, + @IExtensionService private readonly extensionService: IExtensionService, + ) { + this.proxy = context.getProxy(ExtHostContext.ExtHostUriOpeners); + + this.openerService.registerOpener(this); + } + + async open( + target: string | URI, + options?: OpenInternalOptions | OpenExternalOptions + ): Promise { + const targetUri = typeof target === 'string' ? URI.parse(target) : target; + + // Currently we only allow openers for http and https urls + if (targetUri.scheme !== Schemas.http && targetUri.scheme !== Schemas.https) { + return false; + } + + await this.extensionService.activateByEvent(`onUriOpen:${targetUri.scheme}`); + + // No handlers so no point in making a rount trip + if (!this.handlers.size) { + return false; + } + + return await this.proxy.$openUri(targetUri, CancellationToken.None); + } + + async $registerUriOpener(handle: number): Promise { + this.handlers.add(handle); + } + + async $unregisterUriOpener(handle: number): Promise { + this.handlers.delete(handle); + } + + dispose(): void { + this.handlers.clear(); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b7ec8703834..00564b6db78 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -82,6 +82,7 @@ import { ExtHostWebviewPanels } from 'vs/workbench/api/common/extHostWebviewPane import { ExtHostBulkEdits } from 'vs/workbench/api/common/extHostBulkEdits'; import { IExtHostFileSystemInfo } from 'vs/workbench/api/common/extHostFileSystemInfo'; import { ExtHostTesting } from 'vs/workbench/api/common/extHostTesting'; +import { ExtHostUriOpeners } from 'vs/workbench/api/common/extHostUriOpener'; export interface IExtensionApiFactory { (extension: IExtensionDescription, registry: ExtensionDescriptionRegistry, configProvider: ExtHostConfigProvider): typeof vscode; @@ -154,6 +155,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); const extHostTesting = rpcProtocol.set(ExtHostContext.ExtHostTesting, new ExtHostTesting(rpcProtocol, extHostDocumentsAndEditors, extHostWorkspace)); + const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol, extHostCommands, extHostQuickOpen)); // Check that no named customers are missing const expected: ProxyIdentifier[] = values(ExtHostContext); @@ -691,7 +693,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I showNotebookDocument(document, options?) { checkProposedApiEnabled(extension); return extHostNotebook.showNotebookDocument(document, options); - } + }, + registerExternalUriOpener(schemes: readonly string[], opener: vscode.ExternalUriOpener) { + checkProposedApiEnabled(extension); + return extHostUriOpeners.registerUriOpener(extension.identifier, schemes, opener); + }, }; // namespace: workspace diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 733bd6f3971..ee9054a3055 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -788,6 +788,15 @@ export interface ExtHostUrlsShape { $handleExternalUri(handle: number, uri: UriComponents): Promise; } +export interface MainThreadUriOpenersShape extends IDisposable { + $registerUriOpener(handle: number): Promise; + $unregisterUriOpener(handle: number): Promise; +} + +export interface ExtHostUriOpenersShape { + $openUri(uri: UriComponents, token: CancellationToken): Promise; +} + export interface ITextSearchComplete { limitHit?: boolean; } @@ -1821,6 +1830,7 @@ export const MainContext = { MainThreadWebviewViews: createMainId('MainThreadWebviewViews'), MainThreadCustomEditors: createMainId('MainThreadCustomEditors'), MainThreadUrls: createMainId('MainThreadUrls'), + MainThreadUriOpeners: createMainId('MainThreadUriOpeners'), MainThreadWorkspace: createMainId('MainThreadWorkspace'), MainThreadFileSystem: createMainId('MainThreadFileSystem'), MainThreadExtensionService: createMainId('MainThreadExtensionService'), @@ -1870,6 +1880,7 @@ export const ExtHostContext = { ExtHostComments: createMainId('ExtHostComments'), ExtHostStorage: createMainId('ExtHostStorage'), ExtHostUrls: createExtId('ExtHostUrls'), + ExtHostUriOpeners: createExtId('ExtHostUriOpeners'), ExtHostOutputService: createMainId('ExtHostOutputService'), ExtHosLabelService: createMainId('ExtHostLabelService'), ExtHostNotebook: createMainId('ExtHostNotebook'), diff --git a/src/vs/workbench/api/common/extHostUriOpener.ts b/src/vs/workbench/api/common/extHostUriOpener.ts new file mode 100644 index 00000000000..b0260552e30 --- /dev/null +++ b/src/vs/workbench/api/common/extHostUriOpener.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { coalesce } from 'vs/base/common/arrays'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { toDisposable } from 'vs/base/common/lifecycle'; +import { URI, UriComponents } from 'vs/base/common/uri'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; +import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands'; +import { ExtHostQuickOpen } from 'vs/workbench/api/common/extHostQuickOpen'; +import type * as vscode from 'vscode'; +import { ExtHostUriOpenersShape, IMainContext, MainContext, MainThreadUriOpenersShape } from './extHost.protocol'; + +export class ExtHostUriOpeners implements ExtHostUriOpenersShape { + + private static HandlePool = 0; + + private readonly _proxy: MainThreadUriOpenersShape; + private readonly _commands: ExtHostCommands; + private readonly _quickOpen: ExtHostQuickOpen; + + private readonly _openers = new Map, opener: vscode.ExternalUriOpener }>(); + + constructor( + mainContext: IMainContext, + commands: ExtHostCommands, + quickOpen: ExtHostQuickOpen, + ) { + this._proxy = mainContext.getProxy(MainContext.MainThreadUriOpeners); + this._commands = commands; + this._quickOpen = quickOpen; + } + + registerUriOpener( + extensionId: ExtensionIdentifier, + schemes: readonly string[], + opener: vscode.ExternalUriOpener, + ): vscode.Disposable { + + const handle = ExtHostUriOpeners.HandlePool++; + + this._openers.set(handle, { opener, schemes: new Set(schemes) }); + this._proxy.$registerUriOpener(handle); + + return toDisposable(() => { + this._openers.delete(handle); + this._proxy.$unregisterUriOpener(handle); + }); + } + + async $openUri(uriComponents: UriComponents, token: CancellationToken): Promise { + const uri = URI.revive(uriComponents); + + const promises = Array.from(this._openers.values()).map(async ({ schemes, opener }): Promise => { + if (!schemes.has(uri.scheme)) { + return undefined; + } + + try { + const result = await opener.openExternalUri(uri, {}, token); + if (result) { + return result; + } + } catch (e) { + // noop + } + return undefined; + }); + + const results = coalesce(await Promise.all(promises)); + + if (results.length === 0) { + return false; + } else if (results.length === 1) { + const [command] = results; + await this._commands.executeCommand(command.command, ...(command.arguments ?? [])); + return true; + } else { + type PickItem = vscode.QuickPickItem & { index: number }; + const items = results.map((command, i): PickItem => { + return { + label: command.title, + index: i + }; + }); + const picked = await this._quickOpen.showQuickPick(items, false, {}); + if (picked) { + const command = results[(picked as PickItem).index]; + await this._commands.executeCommand(command.command, ...(command.arguments ?? [])); + return true; + } + + return false; + } + } +}