diff --git a/src/vs/platform/extensionManagement/electron-sandbox/extensionTipsService.ts b/src/vs/platform/extensionManagement/electron-sandbox/extensionTipsService.ts index a335ec9397dae..df30746fabda3 100644 --- a/src/vs/platform/extensionManagement/electron-sandbox/extensionTipsService.ts +++ b/src/vs/platform/extensionManagement/electron-sandbox/extensionTipsService.ts @@ -12,7 +12,7 @@ import { IFileService } from 'vs/platform/files/common/files'; import { isWindows } from 'vs/base/common/platform'; import { isNonEmptyArray } from 'vs/base/common/arrays'; import { IExecutableBasedExtensionTip, IExtensionManagementService, ILocalExtension } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { forEach } from 'vs/base/common/collections'; +import { forEach, IStringDictionary } from 'vs/base/common/collections'; import { IRequestService } from 'vs/platform/request/common/request'; import { ILogService } from 'vs/platform/log/common/log'; import { ExtensionTipsService as BaseExtensionTipsService } from 'vs/platform/extensionManagement/common/extensionTipsService'; @@ -20,6 +20,7 @@ import { timeout } from 'vs/base/common/async'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; import { localize } from 'vs/nls'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; type ExeExtensionRecommendationsClassification = { extensionId: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; @@ -32,6 +33,8 @@ type IExeBasedExtensionTips = { readonly recommendations: { extensionId: string, extensionName: string, isExtensionPack: boolean }[]; }; +const promptedExecutableTipsStorageKey = 'extensionTips/promptedExecutableTips'; + export class ExtensionTipsService extends BaseExtensionTipsService { _serviceBrand: any; @@ -43,6 +46,7 @@ export class ExtensionTipsService extends BaseExtensionTipsService { @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IStorageService private readonly storageService: IStorageService, @IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService, @IFileService fileService: IFileService, @IProductService productService: IProductService, @@ -108,13 +112,14 @@ export class ExtensionTipsService extends BaseExtensionTipsService { } const recommendationsByExe = new Map(); + const promptedExecutableTips = this.getPromptedExecutableTips(); for (const extensionId of recommendations) { const tip = importantExeBasedRecommendations.get(extensionId); - if (tip) { - let tips = recommendationsByExe.get(tip.exeFriendlyName); + if (tip && (!promptedExecutableTips[tip.exeName] || !promptedExecutableTips[tip.exeName].includes(tip.extensionId))) { + let tips = recommendationsByExe.get(tip.exeName); if (!tips) { tips = []; - recommendationsByExe.set(tip.exeFriendlyName, tips); + recommendationsByExe.set(tip.exeName, tips); } tips.push(tip); } @@ -123,10 +128,25 @@ export class ExtensionTipsService extends BaseExtensionTipsService { for (const [, tips] of recommendationsByExe) { const extensionIds = tips.map(({ extensionId }) => extensionId.toLowerCase()); const message = localize('exeRecommended', "You have {0} installed on your system. Do you want to install the recommended extensions for it?", tips[0].exeFriendlyName); - this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification(extensionIds, message, `@exe:"${tips[0].exeName}"`); + this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification(extensionIds, message, `@exe:"${tips[0].exeName}"`) + .then(result => { + if (result) { + this.addToRecommendedExecutables(tips[0].exeName, extensionIds); + } + }); } } + private getPromptedExecutableTips(): IStringDictionary { + return JSON.parse(this.storageService.get(promptedExecutableTipsStorageKey, StorageScope.GLOBAL, '{}')); + } + + private addToRecommendedExecutables(exeName: string, extensions: string[]) { + const promptedExecutableTips = this.getPromptedExecutableTips(); + promptedExecutableTips[exeName] = extensions; + this.storageService.store(promptedExecutableTipsStorageKey, JSON.stringify(promptedExecutableTips), StorageScope.GLOBAL); + } + private groupByInstalled(recommendationsToSuggest: string[], local: ILocalExtension[]): { installed: string[], uninstalled: string[] } { const installed: string[] = [], uninstalled: string[] = []; const installedExtensionsIds = local.reduce((result, i) => { result.add(i.identifier.id.toLowerCase()); return result; }, new Set()); diff --git a/src/vs/platform/extensionRecommendations/electron-sandbox/extensionRecommendationsIpc.ts b/src/vs/platform/extensionRecommendations/electron-sandbox/extensionRecommendationsIpc.ts index c5896d18eda7c..b70b75dee0d2d 100644 --- a/src/vs/platform/extensionRecommendations/electron-sandbox/extensionRecommendationsIpc.ts +++ b/src/vs/platform/extensionRecommendations/electron-sandbox/extensionRecommendationsIpc.ts @@ -39,7 +39,7 @@ export class ExtensionRecommendationNotificationServiceChannel implements IServe call(_: unknown, command: string, args?: any): Promise { switch (command) { - case 'promptImportantExtensionsInstallNotification': this.service.promptImportantExtensionsInstallNotification(args[0], args[1], args[2]); return Promise.resolve(); + case 'promptImportantExtensionsInstallNotification': return this.service.promptImportantExtensionsInstallNotification(args[0], args[1], args[2]); } throw new Error(`Call not found: ${command}`); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts index a3e55134b19cc..335383064e44e 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts @@ -96,58 +96,64 @@ export class ExtensionRecommendationNotificationService implements IExtensionRec await this.tasExperimentService.getTreatment('wslpopupaa'); } - this.notificationService.prompt(Severity.Info, message, - [{ - label: localize('install', "Install"), - run: async () => { - this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); - await Promise.all(extensions.map(async extension => { - this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'install', extensionId: extension.identifier.id }); - this.extensionsWorkbenchService.open(extension, { pinned: true }); - await this.extensionManagementService.installFromGallery(extension.gallery!); - })); - } - }, { - label: localize('show recommendations', "Show Recommendations"), - run: async () => { - for (const extension of extensions) { - this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'show', extensionId: extension.identifier.id }); - this.extensionsWorkbenchService.open(extension, { pinned: true }); + return new Promise((c, e) => { + let cancelled: boolean = false; + const handle = this.notificationService.prompt(Severity.Info, message, + [{ + label: localize('install', "Install"), + run: async () => { + this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); + await Promise.all(extensions.map(async extension => { + this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'install', extensionId: extension.identifier.id }); + this.extensionsWorkbenchService.open(extension, { pinned: true }); + await this.extensionManagementService.installFromGallery(extension.gallery!); + })); } - this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); - } - }, { - label: choiceNever, - isSecondary: true, - run: () => { - for (const extension of extensions) { - this.addToImportantRecommendationsIgnore(extension.identifier.id); - this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'neverShowAgain', extensionId: extension.identifier.id }); + }, { + label: localize('show recommendations', "Show Recommendations"), + run: async () => { + for (const extension of extensions) { + this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'show', extensionId: extension.identifier.id }); + this.extensionsWorkbenchService.open(extension, { pinned: true }); + } + this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); } - this.notificationService.prompt( - Severity.Info, - localize('ignoreExtensionRecommendations', "Do you want to ignore all extension recommendations?"), - [{ - label: localize('ignoreAll', "Yes, Ignore All"), - run: () => this.setIgnoreRecommendationsConfig(true) - }, { - label: localize('no', "No"), - run: () => this.setIgnoreRecommendationsConfig(false) - }] - ); - } - }], - { - sticky: true, - onCancel: () => { - for (const extension of extensions) { - this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'cancelled', extensionId: extension.identifier.id }); + }, { + label: choiceNever, + isSecondary: true, + run: () => { + for (const extension of extensions) { + this.addToImportantRecommendationsIgnore(extension.identifier.id); + this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'neverShowAgain', extensionId: extension.identifier.id }); + } + this.notificationService.prompt( + Severity.Info, + localize('ignoreExtensionRecommendations', "Do you want to ignore all extension recommendations?"), + [{ + label: localize('ignoreAll', "Yes, Ignore All"), + run: () => this.setIgnoreRecommendationsConfig(true) + }, { + label: localize('no', "No"), + run: () => this.setIgnoreRecommendationsConfig(false) + }] + ); + } + }], + { + sticky: true, + onCancel: () => { + cancelled = true; + for (const extension of extensions) { + this.telemetryService.publicLog2<{ userReaction: string, extensionId: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'cancelled', extensionId: extension.identifier.id }); + } } } - } - ); - - return true; + ); + const disposable = handle.onDidClose(() => { + disposable.dispose(); + c(!cancelled); + }); + }); } async promptWorkspaceRecommendations(recommendations: string[]): Promise { diff --git a/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts index 7b03d99e1e2ee..8f5cdc37d8c40 100644 --- a/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts @@ -32,6 +32,7 @@ type FileExtensionSuggestionClassification = { fileExtension: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; }; +const promptedRecommendationsStorageKey = 'fileBasedRecommendations/promptedRecommendations'; const recommendationsStorageKey = 'extensionsAssistant/recommendations'; const searchMarketplace = localize('searchMarketplace', "Search Marketplace"); const milliSecondsInADay = 1000 * 60 * 60 * 24; @@ -212,7 +213,7 @@ export class FileBasedRecommendations extends ExtensionRecommendations { const installed = await this.extensionsWorkbenchService.queryLocal(); if (importantRecommendations.length && - await this.promptRecommendedExtensionForFileType(languageName || basename(uri), importantRecommendations, installed)) { + await this.promptRecommendedExtensionForFileType(languageName || basename(uri), language, importantRecommendations, installed)) { return; } @@ -229,7 +230,7 @@ export class FileBasedRecommendations extends ExtensionRecommendations { this.promptRecommendedExtensionForFileExtension(fileExtension, installed); } - private async promptRecommendedExtensionForFileType(name: string, recommendations: string[], installed: IExtension[]): Promise { + private async promptRecommendedExtensionForFileType(name: string, language: string, recommendations: string[], installed: IExtension[]): Promise { recommendations = this.filterIgnoredOrNotAllowed(recommendations); if (recommendations.length === 0) { @@ -247,10 +248,30 @@ export class FileBasedRecommendations extends ExtensionRecommendations { return false; } - this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification([extensionId], localize('reallyRecommended', "Do you want to install the recommended extensions for {0}?", name), `@id:${extensionId}`); + const promptedRecommendations = this.getPromptedRecommendations(); + if (promptedRecommendations[language] && promptedRecommendations[language].includes(extensionId)) { + return false; + } + + this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification([extensionId], localize('reallyRecommended', "Do you want to install the recommended extensions for {0}?", name), `@id:${extensionId}`) + .then(result => { + if (result) { + this.addToPromptedRecommendations(language, [extensionId]); + } + }); return true; } + private getPromptedRecommendations(): IStringDictionary { + return JSON.parse(this.storageService.get(promptedRecommendationsStorageKey, StorageScope.GLOBAL, '{}')); + } + + private addToPromptedRecommendations(exeName: string, extensions: string[]) { + const promptedRecommendations = this.getPromptedRecommendations(); + promptedRecommendations[exeName] = extensions; + this.storageService.store(promptedRecommendationsStorageKey, JSON.stringify(promptedRecommendations), StorageScope.GLOBAL); + } + private async promptRecommendedExtensionForFileExtension(fileExtension: string, installed: IExtension[]): Promise { const fileExtensionSuggestionIgnoreList = JSON.parse(this.storageService.get('extensionsAssistant/fileExtensionsSuggestionIgnore', StorageScope.GLOBAL, '[]')); if (fileExtensionSuggestionIgnoreList.indexOf(fileExtension) > -1) {