Path: blob/main/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts
3296 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 { ExtensionRecommendations, GalleryExtensionRecommendation } from './extensionRecommendations.js';6import { EnablementState } from '../../../services/extensionManagement/common/extensionManagement.js';7import { ExtensionRecommendationReason, IExtensionIgnoredRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js';8import { IExtensionsWorkbenchService, IExtension } from '../common/extensions.js';9import { localize } from '../../../../nls.js';10import { StorageScope, IStorageService, StorageTarget } from '../../../../platform/storage/common/storage.js';11import { IProductService } from '../../../../platform/product/common/productService.js';12import { IFileContentCondition, IFilePathCondition, IFileLanguageCondition, IFileOpenCondition } from '../../../../base/common/product.js';13import { IStringDictionary } from '../../../../base/common/collections.js';14import { ITextModel } from '../../../../editor/common/model.js';15import { Schemas } from '../../../../base/common/network.js';16import { basename, extname } from '../../../../base/common/resources.js';17import { match } from '../../../../base/common/glob.js';18import { URI } from '../../../../base/common/uri.js';19import { IModelService } from '../../../../editor/common/services/model.js';20import { ILanguageService } from '../../../../editor/common/languages/language.js';21import { IExtensionRecommendationNotificationService, RecommendationsNotificationResult, RecommendationSource } from '../../../../platform/extensionRecommendations/common/extensionRecommendations.js';22import { distinct } from '../../../../base/common/arrays.js';23import { DisposableStore } from '../../../../base/common/lifecycle.js';24import { CellUri } from '../../notebook/common/notebookCommon.js';25import { disposableTimeout } from '../../../../base/common/async.js';26import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';27import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js';28import { isEmptyObject } from '../../../../base/common/types.js';29import { PLAINTEXT_LANGUAGE_ID } from '../../../../editor/common/languages/modesRegistry.js';3031const promptedRecommendationsStorageKey = 'fileBasedRecommendations/promptedRecommendations';32const recommendationsStorageKey = 'extensionsAssistant/recommendations';33const milliSecondsInADay = 1000 * 60 * 60 * 24;3435export class FileBasedRecommendations extends ExtensionRecommendations {3637private readonly fileOpenRecommendations: IStringDictionary<IFileOpenCondition[]>;38private readonly recommendationsByPattern = new Map<string, IStringDictionary<IFileOpenCondition[]>>();39private readonly fileBasedRecommendations = new Map<string, { recommendedTime: number }>();40private readonly fileBasedImportantRecommendations = new Set<string>();4142get recommendations(): ReadonlyArray<GalleryExtensionRecommendation> {43const recommendations: GalleryExtensionRecommendation[] = [];44[...this.fileBasedRecommendations.keys()]45.sort((a, b) => {46if (this.fileBasedRecommendations.get(a)!.recommendedTime === this.fileBasedRecommendations.get(b)!.recommendedTime) {47if (this.fileBasedImportantRecommendations.has(a)) {48return -1;49}50if (this.fileBasedImportantRecommendations.has(b)) {51return 1;52}53}54return this.fileBasedRecommendations.get(a)!.recommendedTime > this.fileBasedRecommendations.get(b)!.recommendedTime ? -1 : 1;55})56.forEach(extensionId => {57recommendations.push({58extension: extensionId,59reason: {60reasonId: ExtensionRecommendationReason.File,61reasonText: localize('fileBasedRecommendation', "This extension is recommended based on the files you recently opened.")62}63});64});65return recommendations;66}6768get importantRecommendations(): ReadonlyArray<GalleryExtensionRecommendation> {69return this.recommendations.filter(e => this.fileBasedImportantRecommendations.has(e.extension));70}7172get otherRecommendations(): ReadonlyArray<GalleryExtensionRecommendation> {73return this.recommendations.filter(e => !this.fileBasedImportantRecommendations.has(e.extension));74}7576constructor(77@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,78@IModelService private readonly modelService: IModelService,79@ILanguageService private readonly languageService: ILanguageService,80@IProductService productService: IProductService,81@IStorageService private readonly storageService: IStorageService,82@IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService,83@IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService,84@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,85) {86super();87this.fileOpenRecommendations = {};88if (productService.extensionRecommendations) {89for (const [extensionId, recommendation] of Object.entries(productService.extensionRecommendations)) {90if (recommendation.onFileOpen) {91this.fileOpenRecommendations[extensionId.toLowerCase()] = recommendation.onFileOpen;92}93}94}95}9697protected async doActivate(): Promise<void> {98if (isEmptyObject(this.fileOpenRecommendations)) {99return;100}101102await this.extensionsWorkbenchService.whenInitialized;103104const cachedRecommendations = this.getCachedRecommendations();105const now = Date.now();106// Retire existing recommendations if they are older than a week or are not part of this.productService.extensionTips anymore107Object.entries(cachedRecommendations).forEach(([key, value]) => {108const diff = (now - value) / milliSecondsInADay;109if (diff <= 7 && this.fileOpenRecommendations[key]) {110this.fileBasedRecommendations.set(key.toLowerCase(), { recommendedTime: value });111}112});113114this._register(this.modelService.onModelAdded(model => this.onModelAdded(model)));115this.modelService.getModels().forEach(model => this.onModelAdded(model));116}117118private onModelAdded(model: ITextModel): void {119const uri = model.uri.scheme === Schemas.vscodeNotebookCell ? CellUri.parse(model.uri)?.notebook : model.uri;120if (!uri) {121return;122}123124const supportedSchemes = distinct([Schemas.untitled, Schemas.file, Schemas.vscodeRemote, ...this.workspaceContextService.getWorkspace().folders.map(folder => folder.uri.scheme)]);125if (!uri || !supportedSchemes.includes(uri.scheme)) {126return;127}128129// re-schedule this bit of the operation to be off the critical path - in case glob-match is slow130disposableTimeout(() => this.promptImportantRecommendations(uri, model), 0, this._store);131}132133/**134* Prompt the user to either install the recommended extension for the file type in the current editor model135* or prompt to search the marketplace if it has extensions that can support the file type136*/137private promptImportantRecommendations(uri: URI, model: ITextModel, extensionRecommendations?: IStringDictionary<IFileOpenCondition[]>): void {138if (model.isDisposed()) {139return;140}141142const pattern = extname(uri).toLowerCase();143extensionRecommendations = extensionRecommendations ?? this.recommendationsByPattern.get(pattern) ?? this.fileOpenRecommendations;144const extensionRecommendationEntries = Object.entries(extensionRecommendations);145if (extensionRecommendationEntries.length === 0) {146return;147}148149const processedPathGlobs = new Map<string, boolean>();150const installed = this.extensionsWorkbenchService.local;151const recommendationsByPattern: IStringDictionary<IFileOpenCondition[]> = {};152const matchedRecommendations: IStringDictionary<IFileOpenCondition[]> = {};153const unmatchedRecommendations: IStringDictionary<IFileOpenCondition[]> = {};154let listenOnLanguageChange = false;155const languageId = model.getLanguageId();156157for (const [extensionId, conditions] of extensionRecommendationEntries) {158const conditionsByPattern: IFileOpenCondition[] = [];159const matchedConditions: IFileOpenCondition[] = [];160const unmatchedConditions: IFileOpenCondition[] = [];161for (const condition of conditions) {162let languageMatched = false;163let pathGlobMatched = false;164165const isLanguageCondition = !!(<IFileLanguageCondition>condition).languages;166const isFileContentCondition = !!(<IFileContentCondition>condition).contentPattern;167if (isLanguageCondition || isFileContentCondition) {168conditionsByPattern.push(condition);169}170171if (isLanguageCondition) {172if ((<IFileLanguageCondition>condition).languages.includes(languageId)) {173languageMatched = true;174}175}176177if ((<IFilePathCondition>condition).pathGlob) {178const pathGlob = (<IFilePathCondition>condition).pathGlob;179if (processedPathGlobs.get(pathGlob) ?? match((<IFilePathCondition>condition).pathGlob, uri.with({ fragment: '' }).toString())) {180pathGlobMatched = true;181}182processedPathGlobs.set(pathGlob, pathGlobMatched);183}184185let matched = languageMatched || pathGlobMatched;186187// If the resource has pattern (extension) and not matched, then we don't need to check the other conditions188if (pattern && !matched) {189continue;190}191192if (matched && condition.whenInstalled) {193if (!condition.whenInstalled.every(id => installed.some(local => areSameExtensions({ id }, local.identifier)))) {194matched = false;195}196}197198if (matched && condition.whenNotInstalled) {199if (installed.some(local => condition.whenNotInstalled?.some(id => areSameExtensions({ id }, local.identifier)))) {200matched = false;201}202}203204if (matched && isFileContentCondition) {205if (!model.findMatches((<IFileContentCondition>condition).contentPattern, false, true, false, null, false).length) {206matched = false;207}208}209210if (matched) {211matchedConditions.push(condition);212conditionsByPattern.pop();213} else {214if (isLanguageCondition || isFileContentCondition) {215unmatchedConditions.push(condition);216if (isLanguageCondition) {217listenOnLanguageChange = true;218}219}220}221222}223if (matchedConditions.length) {224matchedRecommendations[extensionId] = matchedConditions;225}226if (unmatchedConditions.length) {227unmatchedRecommendations[extensionId] = unmatchedConditions;228}229if (conditionsByPattern.length) {230recommendationsByPattern[extensionId] = conditionsByPattern;231}232}233234if (pattern) {235this.recommendationsByPattern.set(pattern, recommendationsByPattern);236}237if (Object.keys(unmatchedRecommendations).length) {238if (listenOnLanguageChange) {239const disposables = new DisposableStore();240disposables.add(model.onDidChangeLanguage(() => {241// re-schedule this bit of the operation to be off the critical path - in case glob-match is slow242disposableTimeout(() => {243if (!disposables.isDisposed) {244this.promptImportantRecommendations(uri, model, unmatchedRecommendations);245disposables.dispose();246}247}, 0, disposables);248}));249disposables.add(model.onWillDispose(() => disposables.dispose()));250}251}252253if (Object.keys(matchedRecommendations).length) {254this.promptFromRecommendations(uri, model, matchedRecommendations);255}256}257258private promptFromRecommendations(uri: URI, model: ITextModel, extensionRecommendations: IStringDictionary<IFileOpenCondition[]>): void {259let isImportantRecommendationForLanguage = false;260const importantRecommendations = new Set<string>();261const fileBasedRecommendations = new Set<string>();262for (const [extensionId, conditions] of Object.entries(extensionRecommendations)) {263for (const condition of conditions) {264fileBasedRecommendations.add(extensionId);265if (condition.important) {266importantRecommendations.add(extensionId);267this.fileBasedImportantRecommendations.add(extensionId);268}269if ((<IFileLanguageCondition>condition).languages) {270isImportantRecommendationForLanguage = true;271}272}273}274275// Update file based recommendations276for (const recommendation of fileBasedRecommendations) {277const filedBasedRecommendation = this.fileBasedRecommendations.get(recommendation) || { recommendedTime: Date.now(), sources: [] };278filedBasedRecommendation.recommendedTime = Date.now();279this.fileBasedRecommendations.set(recommendation, filedBasedRecommendation);280}281282this.storeCachedRecommendations();283284if (this.extensionRecommendationNotificationService.hasToIgnoreRecommendationNotifications()) {285return;286}287288const language = model.getLanguageId();289const languageName = this.languageService.getLanguageName(language);290if (importantRecommendations.size &&291this.promptRecommendedExtensionForFileType(languageName && isImportantRecommendationForLanguage && language !== PLAINTEXT_LANGUAGE_ID ? localize('languageName', "the {0} language", languageName) : basename(uri), language, [...importantRecommendations])) {292return;293}294}295296private promptRecommendedExtensionForFileType(name: string, language: string, recommendations: string[]): boolean {297recommendations = this.filterIgnoredOrNotAllowed(recommendations);298if (recommendations.length === 0) {299return false;300}301302recommendations = this.filterInstalled(recommendations, this.extensionsWorkbenchService.local)303.filter(extensionId => this.fileBasedImportantRecommendations.has(extensionId));304305const promptedRecommendations = language !== PLAINTEXT_LANGUAGE_ID ? this.getPromptedRecommendations()[language] : undefined;306if (promptedRecommendations) {307recommendations = recommendations.filter(extensionId => !promptedRecommendations.includes(extensionId));308}309310if (recommendations.length === 0) {311return false;312}313314this.promptImportantExtensionsInstallNotification(recommendations, name, language);315return true;316}317318private async promptImportantExtensionsInstallNotification(extensions: string[], name: string, language: string): Promise<void> {319try {320const result = await this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification({ extensions, name, source: RecommendationSource.FILE });321if (result === RecommendationsNotificationResult.Accepted) {322this.addToPromptedRecommendations(language, extensions);323}324} catch (error) { /* Ignore */ }325}326327private getPromptedRecommendations(): IStringDictionary<string[]> {328return JSON.parse(this.storageService.get(promptedRecommendationsStorageKey, StorageScope.PROFILE, '{}'));329}330331private addToPromptedRecommendations(language: string, extensions: string[]) {332const promptedRecommendations = this.getPromptedRecommendations();333promptedRecommendations[language] = distinct([...(promptedRecommendations[language] ?? []), ...extensions]);334this.storageService.store(promptedRecommendationsStorageKey, JSON.stringify(promptedRecommendations), StorageScope.PROFILE, StorageTarget.USER);335}336337private filterIgnoredOrNotAllowed(recommendationsToSuggest: string[]): string[] {338const ignoredRecommendations = [...this.extensionIgnoredRecommendationsService.ignoredRecommendations, ...this.extensionRecommendationNotificationService.ignoredRecommendations];339return recommendationsToSuggest.filter(id => !ignoredRecommendations.includes(id));340}341342private filterInstalled(recommendationsToSuggest: string[], installed: IExtension[]): string[] {343const installedExtensionsIds = installed.reduce((result, i) => {344if (i.enablementState !== EnablementState.DisabledByExtensionKind) {345result.add(i.identifier.id.toLowerCase());346}347return result;348}, new Set<string>());349return recommendationsToSuggest.filter(id => !installedExtensionsIds.has(id.toLowerCase()));350}351352private getCachedRecommendations(): IStringDictionary<number> {353let storedRecommendations = JSON.parse(this.storageService.get(recommendationsStorageKey, StorageScope.PROFILE, '[]'));354if (Array.isArray(storedRecommendations)) {355storedRecommendations = storedRecommendations.reduce<IStringDictionary<number>>((result, id) => { result[id] = Date.now(); return result; }, {});356}357const result: IStringDictionary<number> = {};358Object.entries(storedRecommendations).forEach(([key, value]) => {359if (typeof value === 'number') {360result[key.toLowerCase()] = value;361}362});363return result;364}365366private storeCachedRecommendations(): void {367const storedRecommendations: IStringDictionary<number> = {};368this.fileBasedRecommendations.forEach((value, key) => storedRecommendations[key] = value.recommendedTime);369this.storageService.store(recommendationsStorageKey, JSON.stringify(storedRecommendations), StorageScope.PROFILE, StorageTarget.MACHINE);370}371}372373374