diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 235a0e96096..65d866972a1 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -12,6 +12,7 @@ const localize = nls.loadMessageBundle(); const openApiCommand = 'simpleBrowser.api.open'; const showCommand = 'simpleBrowser.show'; +const internalOpenCommand = '_simpleBrowser.open'; export function activate(context: vscode.ExtensionContext) { @@ -35,8 +36,12 @@ export function activate(context: vscode.ExtensionContext) { manager.show(url.toString(), showOptions); })); + context.subscriptions.push(vscode.commands.registerCommand(internalOpenCommand, (resolvedUrl: vscode.Uri, originalUri: vscode.Uri) => { + manager.show(resolvedUrl.toString()); + })); + context.subscriptions.push(vscode.window.registerExternalUriOpener(['http', 'https'], { - openExternalUri(uri: vscode.Uri, context: vscode.OpenExternalUriContext): vscode.Command | undefined { + openExternalUri(uri: vscode.Uri): vscode.Command | undefined { const configuration = vscode.workspace.getConfiguration('simpleBrowser'); if (!configuration.get('opener.enabled', false)) { return undefined; @@ -47,10 +52,7 @@ export function activate(context: vscode.ExtensionContext) { '127.0.0.1' ]); try { - // Check against the original uri that triggered the open. - // We check this since the `uri` passed to us may have been transformed - // by port forwarding. - const originalUri = new URL(context.originalUri.toString()); + const originalUri = new URL(uri.toString()); if (!enabledHosts.includes(originalUri.hostname)) { return; } @@ -60,7 +62,7 @@ export function activate(context: vscode.ExtensionContext) { return { title: localize('openTitle', "Open in simple browser"), - command: openApiCommand, + command: internalOpenCommand, arguments: [uri] }; } diff --git a/src/vs/editor/browser/services/openerService.ts b/src/vs/editor/browser/services/openerService.ts index c7250b26f39..bb1362664a9 100644 --- a/src/vs/editor/browser/services/openerService.ts +++ b/src/vs/editor/browser/services/openerService.ts @@ -14,7 +14,7 @@ import { URI } from 'vs/base/common/uri'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { EditorOpenContext } from 'vs/platform/editor/common/editor'; -import { IExternalOpener, IExternalUriResolver, IOpener, IOpenerService, IResolvedExternalUri, IValidator, matchesScheme, OpenOptions, ResolveExternalUriOptions } from 'vs/platform/opener/common/opener'; +import { IExternalOpener, IExternalOpenerProvider, IExternalUriResolver, IOpener, IOpenerService, IResolvedExternalUri, IValidator, matchesScheme, OpenOptions, ResolveExternalUriOptions } from 'vs/platform/opener/common/opener'; class CommandOpener implements IOpener { @@ -100,7 +100,7 @@ export class OpenerService implements IOpenerService { private readonly _resolvedUriTargets = new ResourceMap(uri => uri.with({ path: null, fragment: null, query: null }).toString()); private _defaultExternalOpener: IExternalOpener; - private readonly _additionalExternalOpeners = new LinkedList(); + private readonly _externalOpenerProviders = new LinkedList(); constructor( @ICodeEditorService editorService: ICodeEditorService, @@ -156,8 +156,8 @@ export class OpenerService implements IOpenerService { this._defaultExternalOpener = externalOpener; } - registerAdditionalExternalOpener(externalOpener: IExternalOpener): IDisposable { - const remove = this._additionalExternalOpeners.push(externalOpener); + registerExternalOpenerProvider(provide: IExternalOpenerProvider): IDisposable { + const remove = this._externalOpenerProviders.push(provide); return { dispose: remove }; } @@ -210,12 +210,14 @@ export class OpenerService implements IOpenerService { href = encodeURI(resolved.toString(true)); } - for (const opener of this._additionalExternalOpeners) { - if (await opener.openExternal(href, uri)) { - return true; + for (const provider of this._externalOpenerProviders) { + const opener = await provider.provideExternalOpener(resource); + if (opener) { + return opener.openExternal(href); } } - return this._defaultExternalOpener.openExternal(encodeURI(resolved.toString(true)), uri); + + return this._defaultExternalOpener.openExternal(href); } dispose() { diff --git a/src/vs/platform/opener/common/opener.ts b/src/vs/platform/opener/common/opener.ts index bcff509dd26..10f8dd84308 100644 --- a/src/vs/platform/opener/common/opener.ts +++ b/src/vs/platform/opener/common/opener.ts @@ -46,7 +46,11 @@ export interface IOpener { } export interface IExternalOpener { - openExternal(href: string, originalUri: URI): Promise; + openExternal(href: string): Promise; +} + +export interface IExternalOpenerProvider { + provideExternalOpener(resource: URI | string): Promise; } export interface IValidator { @@ -84,10 +88,9 @@ export interface IOpenerService { setDefaultExternalOpener(opener: IExternalOpener): void; /** - * Registers an additional opener for external resources that is checked - * before the default external opener. + * Registers an a provider for external resources openers. */ - registerAdditionalExternalOpener(externalOpener: IExternalOpener): IDisposable; + registerExternalOpenerProvider(provider: IExternalOpenerProvider): IDisposable; /** * Opens a resource, like a webaddress, a document uri, or executes command. @@ -109,7 +112,7 @@ export const NullOpenerService = Object.freeze({ registerValidator() { return Disposable.None; }, registerExternalUriResolver() { return Disposable.None; }, setDefaultExternalOpener() { }, - registerAdditionalExternalOpener() { return Disposable.None; }, + registerExternalOpenerProvider() { return Disposable.None; }, async open() { return false; }, async resolveExternalUri(uri: URI) { return { resolved: uri, dispose() { } }; }, } as IOpenerService); diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 04bfd472be5..490e141ab79 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2306,12 +2306,7 @@ declare module 'vscode' { * Additional metadata about the uri being opened */ interface OpenExternalUriContext { - /** - * The original uri the open was triggered for. - * - * This may differ from the uri passed to `openExternalUri` due to port forwarding. - */ - readonly originalUri: Uri; + } //#endregion @@ -2339,6 +2334,9 @@ declare module 'vscode' { * @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. * + * This command is given the resolved uri to open. This may be different from the original `uri` due + * to port forwarding. + * * If multiple openers are available for a given uri, then the `Command.title` is shown in the UI. */ openExternalUri(uri: Uri, ctx: OpenExternalUriContext, token: CancellationToken): ProviderResult; diff --git a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts index 3a346351929..b2cf81eb12c 100644 --- a/src/vs/workbench/api/browser/mainThreadUriOpeners.ts +++ b/src/vs/workbench/api/browser/mainThreadUriOpeners.ts @@ -7,13 +7,14 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { Disposable } from 'vs/base/common/lifecycle'; import { Schemas } from 'vs/base/common/network'; import { URI } from 'vs/base/common/uri'; -import { IExternalOpener, IOpenerService } from 'vs/platform/opener/common/opener'; +import { IExternalOpener, IExternalOpenerProvider, IOpenerService } from 'vs/platform/opener/common/opener'; +import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; 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 extends Disposable implements MainThreadUriOpenersShape, IExternalOpener { +export class MainThreadUriOpeners extends Disposable implements MainThreadUriOpenersShape, IExternalOpenerProvider { private readonly proxy: ExtHostUriOpenersShape; private readonly handlers = new Map }>(); @@ -22,19 +23,19 @@ export class MainThreadUriOpeners extends Disposable implements MainThreadUriOpe context: IExtHostContext, @IOpenerService private readonly openerService: IOpenerService, @IExtensionService private readonly extensionService: IExtensionService, + @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(); this.proxy = context.getProxy(ExtHostContext.ExtHostUriOpeners); - this._register(this.openerService.registerAdditionalExternalOpener(this)); + this._register(this.openerService.registerExternalOpenerProvider(this)); } - - public async openExternal(href: string, originalUri: URI): Promise { - const targetUri = URI.parse(href); + public async provideExternalOpener(href: string | URI): Promise { + const targetUri = typeof href === 'string' ? URI.parse(href) : href; // Currently we only allow openers for http and https urls if (targetUri.scheme !== Schemas.http && targetUri.scheme !== Schemas.https) { - return false; + return undefined; } await this.extensionService.activateByEvent(`onUriOpen:${targetUri.scheme}`); @@ -42,12 +43,47 @@ export class MainThreadUriOpeners extends Disposable implements MainThreadUriOpe // If there are no handlers there is no point in making a round trip const hasHandler = Array.from(this.handlers.values()).some(x => x.schemes.has(targetUri.scheme)); if (!hasHandler) { - return false; + return undefined; } - return await this.proxy.$openUri(targetUri, { - originalUri: originalUri, - }, CancellationToken.None); + + const { openers, cacheId } = await this.proxy.$getOpenersForUri(targetUri, CancellationToken.None); + if (openers.length === 0) { + return undefined; + } else if (openers.length === 1) { + return this.openerForCommand(cacheId, openers[0].id); + } else { + type PickItem = IQuickPickItem & { index: number }; + const items = openers.map((opener, i): PickItem => { + return { + label: opener.title, + index: i + }; + }); + + const picked = await this.quickInputService.pick(items, {}); + if (picked) { + const opener = openers[(picked as PickItem).index]; + return this.openerForCommand(cacheId, opener.id); + } + + this.proxy.$releaseOpener(cacheId); + return undefined; + } + } + + private openerForCommand(cacheId: number, commandId: number): IExternalOpener { + return { + openExternal: async (href) => { + const targetUri = URI.parse(href); + try { + await this.proxy.$openUri([cacheId, commandId], targetUri); + } finally { + this.proxy.$releaseOpener(cacheId); + } + return true; + } + }; } async $registerUriOpener(handle: number, schemes: readonly string[]): Promise { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5c0a3a6281d..a26f0bcc119 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -158,7 +158,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)); + const extHostUriOpeners = rpcProtocol.set(ExtHostContext.ExtHostUriOpeners, new ExtHostUriOpeners(rpcProtocol, extHostCommands)); // Check that no named customers are missing const expected: ProxyIdentifier[] = values(ExtHostContext); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index f1aeffc08f0..1da90521b7b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -806,7 +806,9 @@ export interface MainThreadUriOpenersShape extends IDisposable { } export interface ExtHostUriOpenersShape { - $openUri(uri: UriComponents, ctx: { originalUri: UriComponents }, token: CancellationToken): Promise; + $getOpenersForUri(uri: UriComponents, token: CancellationToken): Promise<{ cacheId: number, openers: ReadonlyArray<{ id: number, title: string }> }>; + $openUri(id: ChainedCacheId, uri: UriComponents): Promise; + $releaseOpener(cacheId: number): void; } export interface ITextSearchComplete { diff --git a/src/vs/workbench/api/common/extHostUriOpener.ts b/src/vs/workbench/api/common/extHostUriOpener.ts index b66695c96fa..9b866a2aa19 100644 --- a/src/vs/workbench/api/common/extHostUriOpener.ts +++ b/src/vs/workbench/api/common/extHostUriOpener.ts @@ -9,9 +9,9 @@ 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'; +import { Cache } from './cache'; +import { ChainedCacheId, ExtHostUriOpenersShape, IMainContext, MainContext, MainThreadUriOpenersShape } from './extHost.protocol'; export class ExtHostUriOpeners implements ExtHostUriOpenersShape { @@ -19,18 +19,16 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape { private readonly _proxy: MainThreadUriOpenersShape; private readonly _commands: ExtHostCommands; - private readonly _quickOpen: ExtHostQuickOpen; + private readonly _cache = new Cache('CodeAction'); 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( @@ -49,7 +47,7 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape { }); } - async $openUri(uriComponents: UriComponents, ctx: { originalUri: UriComponents }, token: CancellationToken): Promise { + async $getOpenersForUri(uriComponents: UriComponents, token: CancellationToken): Promise<{ cacheId: number, openers: Array<{ id: number, title: string }> }> { const uri = URI.revive(uriComponents); const promises = Array.from(this._openers.values()).map(async ({ schemes, opener }): Promise => { @@ -58,9 +56,7 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape { } try { - const result = await opener.openExternalUri(uri, { - originalUri: URI.revive(ctx.originalUri), - }, token); + const result = await opener.openExternalUri(uri, {}, token); if (result) { return result; @@ -71,30 +67,24 @@ export class ExtHostUriOpeners implements ExtHostUriOpenersShape { return undefined; }); - const results = coalesce(await Promise.all(promises)); + const commands = coalesce(await Promise.all(promises)); + const cacheId = this._cache.add(commands); + return { + cacheId, + openers: commands.map((command, i) => ({ title: command.title, id: i })), + }; + } - 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; + async $openUri(id: ChainedCacheId, uri: UriComponents): Promise { + const command = this._cache.get(id[0], id[1]); + if (!command) { + return; } + + return this._commands.executeCommand(command.command, URI.revive(uri), ...(command.arguments || [])); + } + + $releaseOpener(cacheId: number): void { + this._cache.delete(cacheId); } }