Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts
5266 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 { ExtensionRecommendations, GalleryExtensionRecommendation } from './extensionRecommendations.js';
7
import { EnablementState } from '../../../services/extensionManagement/common/extensionManagement.js';
8
import { ExtensionRecommendationReason, IExtensionIgnoredRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js';
9
import { IExtensionsWorkbenchService, IExtension } from '../common/extensions.js';
10
import { localize } from '../../../../nls.js';
11
import { StorageScope, IStorageService, StorageTarget } from '../../../../platform/storage/common/storage.js';
12
import { IProductService } from '../../../../platform/product/common/productService.js';
13
import { IFileContentCondition, IFilePathCondition, IFileLanguageCondition, IFileOpenCondition } from '../../../../base/common/product.js';
14
import { IStringDictionary } from '../../../../base/common/collections.js';
15
import { ITextModel } from '../../../../editor/common/model.js';
16
import { Schemas } from '../../../../base/common/network.js';
17
import { basename, extname } from '../../../../base/common/resources.js';
18
import { match } from '../../../../base/common/glob.js';
19
import { URI } from '../../../../base/common/uri.js';
20
import { IModelService } from '../../../../editor/common/services/model.js';
21
import { ILanguageService } from '../../../../editor/common/languages/language.js';
22
import { IExtensionRecommendationNotificationService, RecommendationsNotificationResult, RecommendationSource } from '../../../../platform/extensionRecommendations/common/extensionRecommendations.js';
23
import { distinct } from '../../../../base/common/arrays.js';
24
import { DisposableStore } from '../../../../base/common/lifecycle.js';
25
import { CellUri } from '../../notebook/common/notebookCommon.js';
26
import { disposableTimeout } from '../../../../base/common/async.js';
27
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
28
import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js';
29
import { isEmptyObject } from '../../../../base/common/types.js';
30
import { PLAINTEXT_LANGUAGE_ID } from '../../../../editor/common/languages/modesRegistry.js';
31
import { IUntitledTextEditorService } from '../../../services/untitled/common/untitledTextEditorService.js';
32
33
const promptedRecommendationsStorageKey = 'fileBasedRecommendations/promptedRecommendations';
34
const recommendationsStorageKey = 'extensionsAssistant/recommendations';
35
const milliSecondsInADay = 1000 * 60 * 60 * 24;
36
37
// Minimum length of untitled file to allow triggering extension recommendations for auto-detected language.
38
const untitledFileRecommendationsMinLength = 1000;
39
40
export class FileBasedRecommendations extends ExtensionRecommendations {
41
42
private readonly fileOpenRecommendations: IStringDictionary<IFileOpenCondition[]>;
43
private readonly recommendationsByPattern = new Map<string, IStringDictionary<IFileOpenCondition[]>>();
44
private readonly fileBasedRecommendations = new Map<string, { recommendedTime: number }>();
45
private readonly fileBasedImportantRecommendations = new Set<string>();
46
47
get recommendations(): ReadonlyArray<GalleryExtensionRecommendation> {
48
const recommendations: GalleryExtensionRecommendation[] = [];
49
[...this.fileBasedRecommendations.keys()]
50
.sort((a, b) => {
51
if (this.fileBasedRecommendations.get(a)!.recommendedTime === this.fileBasedRecommendations.get(b)!.recommendedTime) {
52
if (this.fileBasedImportantRecommendations.has(a)) {
53
return -1;
54
}
55
if (this.fileBasedImportantRecommendations.has(b)) {
56
return 1;
57
}
58
}
59
return this.fileBasedRecommendations.get(a)!.recommendedTime > this.fileBasedRecommendations.get(b)!.recommendedTime ? -1 : 1;
60
})
61
.forEach(extensionId => {
62
recommendations.push({
63
extension: extensionId,
64
reason: {
65
reasonId: ExtensionRecommendationReason.File,
66
reasonText: localize('fileBasedRecommendation', "This extension is recommended based on the files you recently opened.")
67
}
68
});
69
});
70
return recommendations;
71
}
72
73
get importantRecommendations(): ReadonlyArray<GalleryExtensionRecommendation> {
74
return this.recommendations.filter(e => this.fileBasedImportantRecommendations.has(e.extension));
75
}
76
77
get otherRecommendations(): ReadonlyArray<GalleryExtensionRecommendation> {
78
return this.recommendations.filter(e => !this.fileBasedImportantRecommendations.has(e.extension));
79
}
80
81
constructor(
82
@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,
83
@IModelService private readonly modelService: IModelService,
84
@ILanguageService private readonly languageService: ILanguageService,
85
@IProductService productService: IProductService,
86
@IStorageService private readonly storageService: IStorageService,
87
@IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService,
88
@IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService,
89
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
90
@IUntitledTextEditorService private readonly untitledTextEditorService: IUntitledTextEditorService,
91
) {
92
super();
93
this.fileOpenRecommendations = {};
94
if (productService.extensionRecommendations) {
95
for (const [extensionId, recommendation] of Object.entries(productService.extensionRecommendations)) {
96
if (recommendation.onFileOpen) {
97
this.fileOpenRecommendations[extensionId.toLowerCase()] = recommendation.onFileOpen;
98
}
99
}
100
}
101
}
102
103
protected async doActivate(): Promise<void> {
104
if (isEmptyObject(this.fileOpenRecommendations)) {
105
return;
106
}
107
108
await this.extensionsWorkbenchService.whenInitialized;
109
110
const cachedRecommendations = this.getCachedRecommendations();
111
const now = Date.now();
112
// Retire existing recommendations if they are older than a week or are not part of this.productService.extensionTips anymore
113
Object.entries(cachedRecommendations).forEach(([key, value]) => {
114
const diff = (now - value) / milliSecondsInADay;
115
if (diff <= 7 && this.fileOpenRecommendations[key]) {
116
this.fileBasedRecommendations.set(key.toLowerCase(), { recommendedTime: value });
117
}
118
});
119
120
this._register(this.modelService.onModelAdded(model => this.onModelAdded(model)));
121
this.modelService.getModels().forEach(model => this.onModelAdded(model));
122
}
123
124
private onModelAdded(model: ITextModel): void {
125
const uri = model.uri.scheme === Schemas.vscodeNotebookCell ? CellUri.parse(model.uri)?.notebook : model.uri;
126
if (!uri) {
127
return;
128
}
129
130
const supportedSchemes = distinct([Schemas.untitled, Schemas.file, Schemas.vscodeRemote, ...this.workspaceContextService.getWorkspace().folders.map(folder => folder.uri.scheme)]);
131
if (!uri || !supportedSchemes.includes(uri.scheme)) {
132
return;
133
}
134
135
// re-schedule this bit of the operation to be off the critical path - in case glob-match is slow
136
disposableTimeout(() => this.promptImportantRecommendations(uri, model), 0, this._store);
137
}
138
139
/**
140
* Prompt the user to either install the recommended extension for the file type in the current editor model
141
* or prompt to search the marketplace if it has extensions that can support the file type
142
*/
143
private promptImportantRecommendations(uri: URI, model: ITextModel, extensionRecommendations?: IStringDictionary<IFileOpenCondition[]>): void {
144
if (model.isDisposed()) {
145
return;
146
}
147
148
const pattern = extname(uri).toLowerCase();
149
extensionRecommendations = extensionRecommendations ?? this.recommendationsByPattern.get(pattern) ?? this.fileOpenRecommendations;
150
const extensionRecommendationEntries = Object.entries(extensionRecommendations);
151
if (extensionRecommendationEntries.length === 0) {
152
return;
153
}
154
155
const processedPathGlobs = new Map<string, boolean>();
156
const installed = this.extensionsWorkbenchService.local;
157
const recommendationsByPattern: IStringDictionary<IFileOpenCondition[]> = {};
158
const matchedRecommendations: IStringDictionary<IFileOpenCondition[]> = {};
159
const unmatchedRecommendations: IStringDictionary<IFileOpenCondition[]> = {};
160
let listenOnLanguageChange = false;
161
const languageId = model.getLanguageId();
162
163
// Allow language-specific recommendations for untitled files when language is auto-detected only when the file is large.
164
const untitledModel = this.untitledTextEditorService.get(uri);
165
const allowLanguageMatch =
166
!untitledModel ||
167
untitledModel.hasLanguageSetExplicitly ||
168
model.getValueLength() > untitledFileRecommendationsMinLength;
169
170
for (const [extensionId, conditions] of extensionRecommendationEntries) {
171
const conditionsByPattern: IFileOpenCondition[] = [];
172
const matchedConditions: IFileOpenCondition[] = [];
173
const unmatchedConditions: IFileOpenCondition[] = [];
174
for (const condition of conditions) {
175
let languageMatched = false;
176
let pathGlobMatched = false;
177
178
const isLanguageCondition = !!(<IFileLanguageCondition>condition).languages;
179
const isFileContentCondition = !!(<IFileContentCondition>condition).contentPattern;
180
if (isLanguageCondition || isFileContentCondition) {
181
conditionsByPattern.push(condition);
182
}
183
184
if (isLanguageCondition && allowLanguageMatch) {
185
if ((<IFileLanguageCondition>condition).languages.includes(languageId)) {
186
languageMatched = true;
187
}
188
}
189
190
const pathGlob = (<IFilePathCondition>condition).pathGlob;
191
if (pathGlob) {
192
if (processedPathGlobs.get(pathGlob) ?? match(pathGlob, uri.with({ fragment: '' }).toString(), { ignoreCase: true })) {
193
pathGlobMatched = true;
194
}
195
processedPathGlobs.set(pathGlob, pathGlobMatched);
196
}
197
198
let matched = languageMatched || pathGlobMatched;
199
200
// If the resource has pattern (extension) and not matched, then we don't need to check the other conditions
201
if (pattern && !matched) {
202
continue;
203
}
204
205
if (matched && condition.whenInstalled) {
206
if (!condition.whenInstalled.every(id => installed.some(local => areSameExtensions({ id }, local.identifier)))) {
207
matched = false;
208
}
209
}
210
211
if (matched && condition.whenNotInstalled) {
212
if (installed.some(local => condition.whenNotInstalled?.some(id => areSameExtensions({ id }, local.identifier)))) {
213
matched = false;
214
}
215
}
216
217
if (matched && isFileContentCondition) {
218
if (!model.findMatches((<IFileContentCondition>condition).contentPattern, false, true, false, null, false).length) {
219
matched = false;
220
}
221
}
222
223
if (matched) {
224
matchedConditions.push(condition);
225
conditionsByPattern.pop();
226
} else {
227
if (isLanguageCondition || isFileContentCondition) {
228
unmatchedConditions.push(condition);
229
if (isLanguageCondition) {
230
listenOnLanguageChange = true;
231
}
232
}
233
}
234
235
}
236
if (matchedConditions.length) {
237
matchedRecommendations[extensionId] = matchedConditions;
238
}
239
if (unmatchedConditions.length) {
240
unmatchedRecommendations[extensionId] = unmatchedConditions;
241
}
242
if (conditionsByPattern.length) {
243
recommendationsByPattern[extensionId] = conditionsByPattern;
244
}
245
}
246
247
if (pattern) {
248
this.recommendationsByPattern.set(pattern, recommendationsByPattern);
249
}
250
if (Object.keys(unmatchedRecommendations).length) {
251
if (listenOnLanguageChange) {
252
const disposables = new DisposableStore();
253
disposables.add(model.onDidChangeLanguage(() => {
254
// re-schedule this bit of the operation to be off the critical path - in case glob-match is slow
255
disposableTimeout(() => {
256
if (!disposables.isDisposed) {
257
this.promptImportantRecommendations(uri, model, unmatchedRecommendations);
258
disposables.dispose();
259
}
260
}, 0, disposables);
261
}));
262
disposables.add(model.onWillDispose(() => disposables.dispose()));
263
}
264
}
265
266
if (Object.keys(matchedRecommendations).length) {
267
this.promptFromRecommendations(uri, model, matchedRecommendations);
268
}
269
}
270
271
private promptFromRecommendations(uri: URI, model: ITextModel, extensionRecommendations: IStringDictionary<IFileOpenCondition[]>): void {
272
let isImportantRecommendationForLanguage = false;
273
const importantRecommendations = new Set<string>();
274
const fileBasedRecommendations = new Set<string>();
275
for (const [extensionId, conditions] of Object.entries(extensionRecommendations)) {
276
for (const condition of conditions) {
277
fileBasedRecommendations.add(extensionId);
278
if (condition.important) {
279
importantRecommendations.add(extensionId);
280
this.fileBasedImportantRecommendations.add(extensionId);
281
}
282
if ((<IFileLanguageCondition>condition).languages) {
283
isImportantRecommendationForLanguage = true;
284
}
285
}
286
}
287
288
// Update file based recommendations
289
for (const recommendation of fileBasedRecommendations) {
290
const filedBasedRecommendation = this.fileBasedRecommendations.get(recommendation) || { recommendedTime: Date.now(), sources: [] };
291
filedBasedRecommendation.recommendedTime = Date.now();
292
this.fileBasedRecommendations.set(recommendation, filedBasedRecommendation);
293
}
294
295
this.storeCachedRecommendations();
296
297
if (this.extensionRecommendationNotificationService.hasToIgnoreRecommendationNotifications()) {
298
return;
299
}
300
301
const language = model.getLanguageId();
302
const languageName = this.languageService.getLanguageName(language);
303
if (importantRecommendations.size &&
304
this.promptRecommendedExtensionForFileType(languageName && isImportantRecommendationForLanguage && language !== PLAINTEXT_LANGUAGE_ID ? localize('languageName', "the {0} language", languageName) : basename(uri), language, [...importantRecommendations])) {
305
return;
306
}
307
}
308
309
private promptRecommendedExtensionForFileType(name: string, language: string, recommendations: string[]): boolean {
310
recommendations = this.filterIgnoredOrNotAllowed(recommendations);
311
if (recommendations.length === 0) {
312
return false;
313
}
314
315
recommendations = this.filterInstalled(recommendations, this.extensionsWorkbenchService.local)
316
.filter(extensionId => this.fileBasedImportantRecommendations.has(extensionId));
317
318
const promptedRecommendations = language !== PLAINTEXT_LANGUAGE_ID ? this.getPromptedRecommendations()[language] : undefined;
319
if (promptedRecommendations) {
320
recommendations = recommendations.filter(extensionId => !promptedRecommendations.includes(extensionId));
321
}
322
323
if (recommendations.length === 0) {
324
return false;
325
}
326
327
this.promptImportantExtensionsInstallNotification(recommendations, name, language);
328
return true;
329
}
330
331
private async promptImportantExtensionsInstallNotification(extensions: string[], name: string, language: string): Promise<void> {
332
try {
333
const result = await this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification({ extensions, name, source: RecommendationSource.FILE });
334
if (result === RecommendationsNotificationResult.Accepted) {
335
this.addToPromptedRecommendations(language, extensions);
336
}
337
} catch (error) { /* Ignore */ }
338
}
339
340
private getPromptedRecommendations(): IStringDictionary<string[]> {
341
return JSON.parse(this.storageService.get(promptedRecommendationsStorageKey, StorageScope.PROFILE, '{}'));
342
}
343
344
private addToPromptedRecommendations(language: string, extensions: string[]) {
345
const promptedRecommendations = this.getPromptedRecommendations();
346
promptedRecommendations[language] = distinct([...(promptedRecommendations[language] ?? []), ...extensions]);
347
this.storageService.store(promptedRecommendationsStorageKey, JSON.stringify(promptedRecommendations), StorageScope.PROFILE, StorageTarget.USER);
348
}
349
350
private filterIgnoredOrNotAllowed(recommendationsToSuggest: string[]): string[] {
351
const ignoredRecommendations = [...this.extensionIgnoredRecommendationsService.ignoredRecommendations, ...this.extensionRecommendationNotificationService.ignoredRecommendations];
352
return recommendationsToSuggest.filter(id => !ignoredRecommendations.includes(id));
353
}
354
355
private filterInstalled(recommendationsToSuggest: string[], installed: IExtension[]): string[] {
356
const installedExtensionsIds = installed.reduce((result, i) => {
357
if (i.enablementState !== EnablementState.DisabledByExtensionKind) {
358
result.add(i.identifier.id.toLowerCase());
359
}
360
return result;
361
}, new Set<string>());
362
return recommendationsToSuggest.filter(id => !installedExtensionsIds.has(id.toLowerCase()));
363
}
364
365
private getCachedRecommendations(): IStringDictionary<number> {
366
let storedRecommendations = JSON.parse(this.storageService.get(recommendationsStorageKey, StorageScope.PROFILE, '[]'));
367
if (Array.isArray(storedRecommendations)) {
368
storedRecommendations = storedRecommendations.reduce<IStringDictionary<number>>((result, id) => { result[id] = Date.now(); return result; }, {});
369
}
370
const result: IStringDictionary<number> = {};
371
Object.entries(storedRecommendations).forEach(([key, value]) => {
372
if (typeof value === 'number') {
373
result[key.toLowerCase()] = value;
374
}
375
});
376
return result;
377
}
378
379
private storeCachedRecommendations(): void {
380
const storedRecommendations: IStringDictionary<number> = {};
381
this.fileBasedRecommendations.forEach((value, key) => storedRecommendations[key] = value.recommendedTime);
382
this.storageService.store(recommendationsStorageKey, JSON.stringify(storedRecommendations), StorageScope.PROFILE, StorageTarget.MACHINE);
383
}
384
}
385
386