Path: blob/main/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts
5266 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';30import { IUntitledTextEditorService } from '../../../services/untitled/common/untitledTextEditorService.js';3132const promptedRecommendationsStorageKey = 'fileBasedRecommendations/promptedRecommendations';33const recommendationsStorageKey = 'extensionsAssistant/recommendations';34const milliSecondsInADay = 1000 * 60 * 60 * 24;3536// Minimum length of untitled file to allow triggering extension recommendations for auto-detected language.37const untitledFileRecommendationsMinLength = 1000;3839export class FileBasedRecommendations extends ExtensionRecommendations {4041private readonly fileOpenRecommendations: IStringDictionary<IFileOpenCondition[]>;42private readonly recommendationsByPattern = new Map<string, IStringDictionary<IFileOpenCondition[]>>();43private readonly fileBasedRecommendations = new Map<string, { recommendedTime: number }>();44private readonly fileBasedImportantRecommendations = new Set<string>();4546get recommendations(): ReadonlyArray<GalleryExtensionRecommendation> {47const recommendations: GalleryExtensionRecommendation[] = [];48[...this.fileBasedRecommendations.keys()]49.sort((a, b) => {50if (this.fileBasedRecommendations.get(a)!.recommendedTime === this.fileBasedRecommendations.get(b)!.recommendedTime) {51if (this.fileBasedImportantRecommendations.has(a)) {52return -1;53}54if (this.fileBasedImportantRecommendations.has(b)) {55return 1;56}57}58return this.fileBasedRecommendations.get(a)!.recommendedTime > this.fileBasedRecommendations.get(b)!.recommendedTime ? -1 : 1;59})60.forEach(extensionId => {61recommendations.push({62extension: extensionId,63reason: {64reasonId: ExtensionRecommendationReason.File,65reasonText: localize('fileBasedRecommendation', "This extension is recommended based on the files you recently opened.")66}67});68});69return recommendations;70}7172get importantRecommendations(): ReadonlyArray<GalleryExtensionRecommendation> {73return this.recommendations.filter(e => this.fileBasedImportantRecommendations.has(e.extension));74}7576get otherRecommendations(): ReadonlyArray<GalleryExtensionRecommendation> {77return this.recommendations.filter(e => !this.fileBasedImportantRecommendations.has(e.extension));78}7980constructor(81@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,82@IModelService private readonly modelService: IModelService,83@ILanguageService private readonly languageService: ILanguageService,84@IProductService productService: IProductService,85@IStorageService private readonly storageService: IStorageService,86@IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService,87@IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService,88@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,89@IUntitledTextEditorService private readonly untitledTextEditorService: IUntitledTextEditorService,90) {91super();92this.fileOpenRecommendations = {};93if (productService.extensionRecommendations) {94for (const [extensionId, recommendation] of Object.entries(productService.extensionRecommendations)) {95if (recommendation.onFileOpen) {96this.fileOpenRecommendations[extensionId.toLowerCase()] = recommendation.onFileOpen;97}98}99}100}101102protected async doActivate(): Promise<void> {103if (isEmptyObject(this.fileOpenRecommendations)) {104return;105}106107await this.extensionsWorkbenchService.whenInitialized;108109const cachedRecommendations = this.getCachedRecommendations();110const now = Date.now();111// Retire existing recommendations if they are older than a week or are not part of this.productService.extensionTips anymore112Object.entries(cachedRecommendations).forEach(([key, value]) => {113const diff = (now - value) / milliSecondsInADay;114if (diff <= 7 && this.fileOpenRecommendations[key]) {115this.fileBasedRecommendations.set(key.toLowerCase(), { recommendedTime: value });116}117});118119this._register(this.modelService.onModelAdded(model => this.onModelAdded(model)));120this.modelService.getModels().forEach(model => this.onModelAdded(model));121}122123private onModelAdded(model: ITextModel): void {124const uri = model.uri.scheme === Schemas.vscodeNotebookCell ? CellUri.parse(model.uri)?.notebook : model.uri;125if (!uri) {126return;127}128129const supportedSchemes = distinct([Schemas.untitled, Schemas.file, Schemas.vscodeRemote, ...this.workspaceContextService.getWorkspace().folders.map(folder => folder.uri.scheme)]);130if (!uri || !supportedSchemes.includes(uri.scheme)) {131return;132}133134// re-schedule this bit of the operation to be off the critical path - in case glob-match is slow135disposableTimeout(() => this.promptImportantRecommendations(uri, model), 0, this._store);136}137138/**139* Prompt the user to either install the recommended extension for the file type in the current editor model140* or prompt to search the marketplace if it has extensions that can support the file type141*/142private promptImportantRecommendations(uri: URI, model: ITextModel, extensionRecommendations?: IStringDictionary<IFileOpenCondition[]>): void {143if (model.isDisposed()) {144return;145}146147const pattern = extname(uri).toLowerCase();148extensionRecommendations = extensionRecommendations ?? this.recommendationsByPattern.get(pattern) ?? this.fileOpenRecommendations;149const extensionRecommendationEntries = Object.entries(extensionRecommendations);150if (extensionRecommendationEntries.length === 0) {151return;152}153154const processedPathGlobs = new Map<string, boolean>();155const installed = this.extensionsWorkbenchService.local;156const recommendationsByPattern: IStringDictionary<IFileOpenCondition[]> = {};157const matchedRecommendations: IStringDictionary<IFileOpenCondition[]> = {};158const unmatchedRecommendations: IStringDictionary<IFileOpenCondition[]> = {};159let listenOnLanguageChange = false;160const languageId = model.getLanguageId();161162// Allow language-specific recommendations for untitled files when language is auto-detected only when the file is large.163const untitledModel = this.untitledTextEditorService.get(uri);164const allowLanguageMatch =165!untitledModel ||166untitledModel.hasLanguageSetExplicitly ||167model.getValueLength() > untitledFileRecommendationsMinLength;168169for (const [extensionId, conditions] of extensionRecommendationEntries) {170const conditionsByPattern: IFileOpenCondition[] = [];171const matchedConditions: IFileOpenCondition[] = [];172const unmatchedConditions: IFileOpenCondition[] = [];173for (const condition of conditions) {174let languageMatched = false;175let pathGlobMatched = false;176177const isLanguageCondition = !!(<IFileLanguageCondition>condition).languages;178const isFileContentCondition = !!(<IFileContentCondition>condition).contentPattern;179if (isLanguageCondition || isFileContentCondition) {180conditionsByPattern.push(condition);181}182183if (isLanguageCondition && allowLanguageMatch) {184if ((<IFileLanguageCondition>condition).languages.includes(languageId)) {185languageMatched = true;186}187}188189const pathGlob = (<IFilePathCondition>condition).pathGlob;190if (pathGlob) {191if (processedPathGlobs.get(pathGlob) ?? match(pathGlob, uri.with({ fragment: '' }).toString(), { ignoreCase: true })) {192pathGlobMatched = true;193}194processedPathGlobs.set(pathGlob, pathGlobMatched);195}196197let matched = languageMatched || pathGlobMatched;198199// If the resource has pattern (extension) and not matched, then we don't need to check the other conditions200if (pattern && !matched) {201continue;202}203204if (matched && condition.whenInstalled) {205if (!condition.whenInstalled.every(id => installed.some(local => areSameExtensions({ id }, local.identifier)))) {206matched = false;207}208}209210if (matched && condition.whenNotInstalled) {211if (installed.some(local => condition.whenNotInstalled?.some(id => areSameExtensions({ id }, local.identifier)))) {212matched = false;213}214}215216if (matched && isFileContentCondition) {217if (!model.findMatches((<IFileContentCondition>condition).contentPattern, false, true, false, null, false).length) {218matched = false;219}220}221222if (matched) {223matchedConditions.push(condition);224conditionsByPattern.pop();225} else {226if (isLanguageCondition || isFileContentCondition) {227unmatchedConditions.push(condition);228if (isLanguageCondition) {229listenOnLanguageChange = true;230}231}232}233234}235if (matchedConditions.length) {236matchedRecommendations[extensionId] = matchedConditions;237}238if (unmatchedConditions.length) {239unmatchedRecommendations[extensionId] = unmatchedConditions;240}241if (conditionsByPattern.length) {242recommendationsByPattern[extensionId] = conditionsByPattern;243}244}245246if (pattern) {247this.recommendationsByPattern.set(pattern, recommendationsByPattern);248}249if (Object.keys(unmatchedRecommendations).length) {250if (listenOnLanguageChange) {251const disposables = new DisposableStore();252disposables.add(model.onDidChangeLanguage(() => {253// re-schedule this bit of the operation to be off the critical path - in case glob-match is slow254disposableTimeout(() => {255if (!disposables.isDisposed) {256this.promptImportantRecommendations(uri, model, unmatchedRecommendations);257disposables.dispose();258}259}, 0, disposables);260}));261disposables.add(model.onWillDispose(() => disposables.dispose()));262}263}264265if (Object.keys(matchedRecommendations).length) {266this.promptFromRecommendations(uri, model, matchedRecommendations);267}268}269270private promptFromRecommendations(uri: URI, model: ITextModel, extensionRecommendations: IStringDictionary<IFileOpenCondition[]>): void {271let isImportantRecommendationForLanguage = false;272const importantRecommendations = new Set<string>();273const fileBasedRecommendations = new Set<string>();274for (const [extensionId, conditions] of Object.entries(extensionRecommendations)) {275for (const condition of conditions) {276fileBasedRecommendations.add(extensionId);277if (condition.important) {278importantRecommendations.add(extensionId);279this.fileBasedImportantRecommendations.add(extensionId);280}281if ((<IFileLanguageCondition>condition).languages) {282isImportantRecommendationForLanguage = true;283}284}285}286287// Update file based recommendations288for (const recommendation of fileBasedRecommendations) {289const filedBasedRecommendation = this.fileBasedRecommendations.get(recommendation) || { recommendedTime: Date.now(), sources: [] };290filedBasedRecommendation.recommendedTime = Date.now();291this.fileBasedRecommendations.set(recommendation, filedBasedRecommendation);292}293294this.storeCachedRecommendations();295296if (this.extensionRecommendationNotificationService.hasToIgnoreRecommendationNotifications()) {297return;298}299300const language = model.getLanguageId();301const languageName = this.languageService.getLanguageName(language);302if (importantRecommendations.size &&303this.promptRecommendedExtensionForFileType(languageName && isImportantRecommendationForLanguage && language !== PLAINTEXT_LANGUAGE_ID ? localize('languageName', "the {0} language", languageName) : basename(uri), language, [...importantRecommendations])) {304return;305}306}307308private promptRecommendedExtensionForFileType(name: string, language: string, recommendations: string[]): boolean {309recommendations = this.filterIgnoredOrNotAllowed(recommendations);310if (recommendations.length === 0) {311return false;312}313314recommendations = this.filterInstalled(recommendations, this.extensionsWorkbenchService.local)315.filter(extensionId => this.fileBasedImportantRecommendations.has(extensionId));316317const promptedRecommendations = language !== PLAINTEXT_LANGUAGE_ID ? this.getPromptedRecommendations()[language] : undefined;318if (promptedRecommendations) {319recommendations = recommendations.filter(extensionId => !promptedRecommendations.includes(extensionId));320}321322if (recommendations.length === 0) {323return false;324}325326this.promptImportantExtensionsInstallNotification(recommendations, name, language);327return true;328}329330private async promptImportantExtensionsInstallNotification(extensions: string[], name: string, language: string): Promise<void> {331try {332const result = await this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification({ extensions, name, source: RecommendationSource.FILE });333if (result === RecommendationsNotificationResult.Accepted) {334this.addToPromptedRecommendations(language, extensions);335}336} catch (error) { /* Ignore */ }337}338339private getPromptedRecommendations(): IStringDictionary<string[]> {340return JSON.parse(this.storageService.get(promptedRecommendationsStorageKey, StorageScope.PROFILE, '{}'));341}342343private addToPromptedRecommendations(language: string, extensions: string[]) {344const promptedRecommendations = this.getPromptedRecommendations();345promptedRecommendations[language] = distinct([...(promptedRecommendations[language] ?? []), ...extensions]);346this.storageService.store(promptedRecommendationsStorageKey, JSON.stringify(promptedRecommendations), StorageScope.PROFILE, StorageTarget.USER);347}348349private filterIgnoredOrNotAllowed(recommendationsToSuggest: string[]): string[] {350const ignoredRecommendations = [...this.extensionIgnoredRecommendationsService.ignoredRecommendations, ...this.extensionRecommendationNotificationService.ignoredRecommendations];351return recommendationsToSuggest.filter(id => !ignoredRecommendations.includes(id));352}353354private filterInstalled(recommendationsToSuggest: string[], installed: IExtension[]): string[] {355const installedExtensionsIds = installed.reduce((result, i) => {356if (i.enablementState !== EnablementState.DisabledByExtensionKind) {357result.add(i.identifier.id.toLowerCase());358}359return result;360}, new Set<string>());361return recommendationsToSuggest.filter(id => !installedExtensionsIds.has(id.toLowerCase()));362}363364private getCachedRecommendations(): IStringDictionary<number> {365let storedRecommendations = JSON.parse(this.storageService.get(recommendationsStorageKey, StorageScope.PROFILE, '[]'));366if (Array.isArray(storedRecommendations)) {367storedRecommendations = storedRecommendations.reduce<IStringDictionary<number>>((result, id) => { result[id] = Date.now(); return result; }, {});368}369const result: IStringDictionary<number> = {};370Object.entries(storedRecommendations).forEach(([key, value]) => {371if (typeof value === 'number') {372result[key.toLowerCase()] = value;373}374});375return result;376}377378private storeCachedRecommendations(): void {379const storedRecommendations: IStringDictionary<number> = {};380this.fileBasedRecommendations.forEach((value, key) => storedRecommendations[key] = value.recommendedTime);381this.storageService.store(recommendationsStorageKey, JSON.stringify(storedRecommendations), StorageScope.PROFILE, StorageTarget.MACHINE);382}383}384385386