Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/externalUriOpener/common/externalUriOpenerService.ts
4780 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken } from '../../../../base/common/cancellation.js';
7
import { Iterable } from '../../../../base/common/iterator.js';
8
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
9
import { LinkedList } from '../../../../base/common/linkedList.js';
10
import { isWeb } from '../../../../base/common/platform.js';
11
import { URI } from '../../../../base/common/uri.js';
12
import * as languages from '../../../../editor/common/languages.js';
13
import * as nls from '../../../../nls.js';
14
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
15
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
16
import { ILogService } from '../../../../platform/log/common/log.js';
17
import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js';
18
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js';
19
import { defaultExternalUriOpenerId, ExternalUriOpenersConfiguration, externalUriOpenersSettingId } from './configuration.js';
20
import { testUrlMatchesGlob } from '../../../../platform/url/common/urlGlob.js';
21
import { IPreferencesService } from '../../../services/preferences/common/preferences.js';
22
23
24
export const IExternalUriOpenerService = createDecorator<IExternalUriOpenerService>('externalUriOpenerService');
25
26
27
export interface IExternalOpenerProvider {
28
getOpeners(targetUri: URI): AsyncIterable<IExternalUriOpener>;
29
}
30
31
export interface IExternalUriOpener {
32
readonly id: string;
33
readonly label: string;
34
35
canOpen(uri: URI, token: CancellationToken): Promise<languages.ExternalUriOpenerPriority>;
36
openExternalUri(uri: URI, ctx: { sourceUri: URI }, token: CancellationToken): Promise<boolean>;
37
}
38
39
export interface IExternalUriOpenerService {
40
readonly _serviceBrand: undefined;
41
42
/**
43
* Registers a provider for external resources openers.
44
*/
45
registerExternalOpenerProvider(provider: IExternalOpenerProvider): IDisposable;
46
47
/**
48
* Get the configured IExternalUriOpener for the uri.
49
* If there is no opener configured, then returns the first opener that can handle the uri.
50
*/
51
getOpener(uri: URI, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<IExternalUriOpener | undefined>;
52
}
53
54
export class ExternalUriOpenerService extends Disposable implements IExternalUriOpenerService, IExternalOpener {
55
56
public readonly _serviceBrand: undefined;
57
58
private readonly _providers = new LinkedList<IExternalOpenerProvider>();
59
60
constructor(
61
@IOpenerService openerService: IOpenerService,
62
@IConfigurationService private readonly configurationService: IConfigurationService,
63
@ILogService private readonly logService: ILogService,
64
@IPreferencesService private readonly preferencesService: IPreferencesService,
65
@IQuickInputService private readonly quickInputService: IQuickInputService,
66
) {
67
super();
68
this._register(openerService.registerExternalOpener(this));
69
}
70
71
registerExternalOpenerProvider(provider: IExternalOpenerProvider): IDisposable {
72
const remove = this._providers.push(provider);
73
return { dispose: remove };
74
}
75
76
private async getOpeners(targetUri: URI, allowOptional: boolean, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<IExternalUriOpener[]> {
77
const allOpeners = await this.getAllOpenersForUri(targetUri);
78
79
if (allOpeners.size === 0) {
80
return [];
81
}
82
83
// First see if we have a preferredOpener
84
if (ctx.preferredOpenerId) {
85
if (ctx.preferredOpenerId === defaultExternalUriOpenerId) {
86
return [];
87
}
88
89
const preferredOpener = allOpeners.get(ctx.preferredOpenerId);
90
if (preferredOpener) {
91
// Skip the `canOpen` check here since the opener was specifically requested.
92
return [preferredOpener];
93
}
94
}
95
96
// Check to see if we have a configured opener
97
const configuredOpener = this.getConfiguredOpenerForUri(allOpeners, targetUri);
98
if (configuredOpener) {
99
// Skip the `canOpen` check here since the opener was specifically requested.
100
return configuredOpener === defaultExternalUriOpenerId ? [] : [configuredOpener];
101
}
102
103
// Then check to see if there is a valid opener
104
const validOpeners: Array<{ opener: IExternalUriOpener; priority: languages.ExternalUriOpenerPriority }> = [];
105
await Promise.all(Array.from(allOpeners.values()).map(async opener => {
106
let priority: languages.ExternalUriOpenerPriority;
107
try {
108
priority = await opener.canOpen(ctx.sourceUri, token);
109
} catch (e) {
110
this.logService.error(e);
111
return;
112
}
113
114
switch (priority) {
115
case languages.ExternalUriOpenerPriority.Option:
116
case languages.ExternalUriOpenerPriority.Default:
117
case languages.ExternalUriOpenerPriority.Preferred:
118
validOpeners.push({ opener, priority });
119
break;
120
}
121
}));
122
123
if (validOpeners.length === 0) {
124
return [];
125
}
126
127
// See if we have a preferred opener first
128
const preferred = validOpeners.filter(x => x.priority === languages.ExternalUriOpenerPriority.Preferred).at(0);
129
if (preferred) {
130
return [preferred.opener];
131
}
132
133
// See if we only have optional openers, use the default opener
134
if (!allowOptional && validOpeners.every(x => x.priority === languages.ExternalUriOpenerPriority.Option)) {
135
return [];
136
}
137
138
return validOpeners.map(value => value.opener);
139
}
140
141
async openExternal(href: string, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<boolean> {
142
143
const targetUri = typeof href === 'string' ? URI.parse(href) : href;
144
145
const allOpeners = await this.getOpeners(targetUri, false, ctx, token);
146
if (allOpeners.length === 0) {
147
return false;
148
} else if (allOpeners.length === 1) {
149
return allOpeners[0].openExternalUri(targetUri, ctx, token);
150
}
151
152
// Otherwise prompt
153
return this.showOpenerPrompt(allOpeners, targetUri, ctx, token);
154
}
155
156
async getOpener(targetUri: URI, ctx: { sourceUri: URI; preferredOpenerId?: string }, token: CancellationToken): Promise<IExternalUriOpener | undefined> {
157
const allOpeners = await this.getOpeners(targetUri, true, ctx, token);
158
if (allOpeners.length >= 1) {
159
return allOpeners[0];
160
}
161
return undefined;
162
}
163
164
private async getAllOpenersForUri(targetUri: URI): Promise<Map<string, IExternalUriOpener>> {
165
const allOpeners = new Map<string, IExternalUriOpener>();
166
await Promise.all(Iterable.map(this._providers, async (provider) => {
167
for await (const opener of provider.getOpeners(targetUri)) {
168
allOpeners.set(opener.id, opener);
169
}
170
}));
171
return allOpeners;
172
}
173
174
private getConfiguredOpenerForUri(openers: Map<string, IExternalUriOpener>, targetUri: URI): IExternalUriOpener | 'default' | undefined {
175
const config = this.configurationService.getValue<ExternalUriOpenersConfiguration>(externalUriOpenersSettingId) || {};
176
for (const [uriGlob, id] of Object.entries(config)) {
177
if (testUrlMatchesGlob(targetUri, uriGlob)) {
178
if (id === defaultExternalUriOpenerId) {
179
return 'default';
180
}
181
182
const entry = openers.get(id);
183
if (entry) {
184
return entry;
185
}
186
}
187
}
188
return undefined;
189
}
190
191
private async showOpenerPrompt(
192
openers: ReadonlyArray<IExternalUriOpener>,
193
targetUri: URI,
194
ctx: { sourceUri: URI },
195
token: CancellationToken
196
): Promise<boolean> {
197
type PickItem = IQuickPickItem & { opener?: IExternalUriOpener | 'configureDefault' };
198
199
const items: Array<PickItem | IQuickPickSeparator> = openers.map((opener): PickItem => {
200
return {
201
label: opener.label,
202
opener: opener
203
};
204
});
205
items.push(
206
{
207
label: isWeb
208
? nls.localize('selectOpenerDefaultLabel.web', 'Open in new browser window')
209
: nls.localize('selectOpenerDefaultLabel', 'Open in default browser'),
210
opener: undefined
211
},
212
{ type: 'separator' },
213
{
214
label: nls.localize('selectOpenerConfigureTitle', "Configure default opener..."),
215
opener: 'configureDefault'
216
});
217
218
const picked = await this.quickInputService.pick(items, {
219
placeHolder: nls.localize('selectOpenerPlaceHolder', "How would you like to open: {0}", targetUri.toString())
220
});
221
222
if (!picked) {
223
// Still cancel the default opener here since we prompted the user
224
return true;
225
}
226
227
if (typeof picked.opener === 'undefined') {
228
return false; // Fallback to default opener
229
} else if (picked.opener === 'configureDefault') {
230
await this.preferencesService.openUserSettings({
231
jsonEditor: true,
232
revealSetting: { key: externalUriOpenersSettingId, edit: true }
233
});
234
return true;
235
} else {
236
return picked.opener.openExternalUri(targetUri, ctx, token);
237
}
238
}
239
}
240
241