Path: blob/main/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts
4780 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../base/common/cancellation.js';6import { Iterable } from '../../../../base/common/iterator.js';7import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';8import { LinkedList } from '../../../../base/common/linkedList.js';9import { isWeb } from '../../../../base/common/platform.js';10import { URI } from '../../../../base/common/uri.js';11import * as languages from '../../../../editor/common/languages.js';12import * as nls from '../../../../nls.js';13import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';14import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';15import { ILogService } from '../../../../platform/log/common/log.js';16import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js';17import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';18import { defaultExternalUriOpenerId, ExternalUriOpenersConfiguration, externalUriOpenersSettingId } from './configuration.js';19import { testUrlMatchesGlob } from '../../../../platform/url/common/urlGlob.js';20import { IPreferencesService } from '../../../services/preferences/common/preferences.js';212223export const IExternalUriOpenerService = createDecorator<IExternalUriOpenerService>('externalUriOpenerService');242526export interface IExternalOpenerProvider {27getOpeners(targetUri: URI): AsyncIterable<IExternalUriOpener>;28}2930export interface IExternalUriOpener {31readonly id: string;32readonly label: string;3334canOpen(uri: URI, token: CancellationToken): Promise<languages.ExternalUriOpenerPriority>;35openExternalUri(uri: URI, ctx: { sourceUri: URI }, token: CancellationToken): Promise<boolean>;36}3738export interface IExternalUriOpenerService {39readonly _serviceBrand: undefined;4041/**42* Registers a provider for external resources openers.43*/44registerExternalOpenerProvider(provider: IExternalOpenerProvider): IDisposable;4546/**47* Get the configured IExternalUriOpener for the uri.48* If there is no opener configured, then returns the first opener that can handle the uri.49*/50getOpener(uri: URI, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<IExternalUriOpener | undefined>;51}5253export class ExternalUriOpenerService extends Disposable implements IExternalUriOpenerService, IExternalOpener {5455public readonly _serviceBrand: undefined;5657private readonly _providers = new LinkedList<IExternalOpenerProvider>();5859constructor(60@IOpenerService openerService: IOpenerService,61@IConfigurationService private readonly configurationService: IConfigurationService,62@ILogService private readonly logService: ILogService,63@IPreferencesService private readonly preferencesService: IPreferencesService,64@IQuickInputService private readonly quickInputService: IQuickInputService,65) {66super();67this._register(openerService.registerExternalOpener(this));68}6970registerExternalOpenerProvider(provider: IExternalOpenerProvider): IDisposable {71const remove = this._providers.push(provider);72return { dispose: remove };73}7475private async getOpeners(targetUri: URI, allowOptional: boolean, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<IExternalUriOpener[]> {76const allOpeners = await this.getAllOpenersForUri(targetUri);7778if (allOpeners.size === 0) {79return [];80}8182// First see if we have a preferredOpener83if (ctx.preferredOpenerId) {84if (ctx.preferredOpenerId === defaultExternalUriOpenerId) {85return [];86}8788const preferredOpener = allOpeners.get(ctx.preferredOpenerId);89if (preferredOpener) {90// Skip the `canOpen` check here since the opener was specifically requested.91return [preferredOpener];92}93}9495// Check to see if we have a configured opener96const configuredOpener = this.getConfiguredOpenerForUri(allOpeners, targetUri);97if (configuredOpener) {98// Skip the `canOpen` check here since the opener was specifically requested.99return configuredOpener === defaultExternalUriOpenerId ? [] : [configuredOpener];100}101102// Then check to see if there is a valid opener103const validOpeners: Array<{ opener: IExternalUriOpener; priority: languages.ExternalUriOpenerPriority }> = [];104await Promise.all(Array.from(allOpeners.values()).map(async opener => {105let priority: languages.ExternalUriOpenerPriority;106try {107priority = await opener.canOpen(ctx.sourceUri, token);108} catch (e) {109this.logService.error(e);110return;111}112113switch (priority) {114case languages.ExternalUriOpenerPriority.Option:115case languages.ExternalUriOpenerPriority.Default:116case languages.ExternalUriOpenerPriority.Preferred:117validOpeners.push({ opener, priority });118break;119}120}));121122if (validOpeners.length === 0) {123return [];124}125126// See if we have a preferred opener first127const preferred = validOpeners.filter(x => x.priority === languages.ExternalUriOpenerPriority.Preferred).at(0);128if (preferred) {129return [preferred.opener];130}131132// See if we only have optional openers, use the default opener133if (!allowOptional && validOpeners.every(x => x.priority === languages.ExternalUriOpenerPriority.Option)) {134return [];135}136137return validOpeners.map(value => value.opener);138}139140async openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<boolean> {141142const targetUri = typeof href === 'string' ? URI.parse(href) : href;143144const allOpeners = await this.getOpeners(targetUri, false, ctx, token);145if (allOpeners.length === 0) {146return false;147} else if (allOpeners.length === 1) {148return allOpeners[0].openExternalUri(targetUri, ctx, token);149}150151// Otherwise prompt152return this.showOpenerPrompt(allOpeners, targetUri, ctx, token);153}154155async getOpener(targetUri: URI, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<IExternalUriOpener | undefined> {156const allOpeners = await this.getOpeners(targetUri, true, ctx, token);157if (allOpeners.length >= 1) {158return allOpeners[0];159}160return undefined;161}162163private async getAllOpenersForUri(targetUri: URI): Promise<Map<string, IExternalUriOpener>> {164const allOpeners = new Map<string, IExternalUriOpener>();165await Promise.all(Iterable.map(this._providers, async (provider) => {166for await (const opener of provider.getOpeners(targetUri)) {167allOpeners.set(opener.id, opener);168}169}));170return allOpeners;171}172173private getConfiguredOpenerForUri(openers: Map<string, IExternalUriOpener>, targetUri: URI): IExternalUriOpener | 'default' | undefined {174const config = this.configurationService.getValue<ExternalUriOpenersConfiguration>(externalUriOpenersSettingId) || {};175for (const [uriGlob, id] of Object.entries(config)) {176if (testUrlMatchesGlob(targetUri, uriGlob)) {177if (id === defaultExternalUriOpenerId) {178return 'default';179}180181const entry = openers.get(id);182if (entry) {183return entry;184}185}186}187return undefined;188}189190private async showOpenerPrompt(191openers: ReadonlyArray<IExternalUriOpener>,192targetUri: URI,193ctx: { sourceUri: URI },194token: CancellationToken195): Promise<boolean> {196type PickItem = IQuickPickItem & { opener?: IExternalUriOpener | 'configureDefault' };197198const items: Array<PickItem | IQuickPickSeparator> = openers.map((opener): PickItem => {199return {200label: opener.label,201opener: opener202};203});204items.push(205{206label: isWeb207? nls.localize('selectOpenerDefaultLabel.web', 'Open in new browser window')208: nls.localize('selectOpenerDefaultLabel', 'Open in default browser'),209opener: undefined210},211{ type: 'separator' },212{213label: nls.localize('selectOpenerConfigureTitle', "Configure default opener..."),214opener: 'configureDefault'215});216217const picked = await this.quickInputService.pick(items, {218placeHolder: nls.localize('selectOpenerPlaceHolder', "How would you like to open: {0}", targetUri.toString())219});220221if (!picked) {222// Still cancel the default opener here since we prompted the user223return true;224}225226if (typeof picked.opener === 'undefined') {227return false; // Fallback to default opener228} else if (picked.opener === 'configureDefault') {229await this.preferencesService.openUserSettings({230jsonEditor: true,231revealSetting: { key: externalUriOpenersSettingId, edit: true }232});233return true;234} else {235return picked.opener.openExternalUri(targetUri, ctx, token);236}237}238}239240241