diff --git a/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts b/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts new file mode 100644 index 00000000000..21d3716dd25 --- /dev/null +++ b/src/vs/platform/extensionRecommendations/common/extensionRecommendations.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; + +export const IExtensionRecommendationNotificationService = createDecorator('IExtensionRecommendationNotificationService'); + +export interface IExtensionRecommendationNotificationService { + readonly _serviceBrand: undefined; + + readonly ignoredRecommendations: string[]; + hasToIgnoreRecommendationNotifications(): boolean; + + promptImportantExtensionsInstallNotification(extensionIds: string[], message: string, searchValue: string): Promise; + promptWorkspaceRecommendations(recommendations: string[]): Promise; +} + diff --git a/src/vs/workbench/contrib/extensions/browser/configBasedRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/configBasedRecommendations.ts index e159ddea7a5..c8e16822555 100644 --- a/src/vs/workbench/contrib/extensions/browser/configBasedRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/configBasedRecommendations.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IExtensionTipsService, IConfigBasedExtensionTip } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { localize } from 'vs/nls'; import { ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; @@ -27,11 +27,10 @@ export class ConfigBasedRecommendations extends ExtensionRecommendations { get recommendations(): ReadonlyArray { return [...this.importantRecommendations, ...this.otherRecommendations]; } constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, @IExtensionTipsService private readonly extensionTipsService: IExtensionTipsService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, ) { - super(promptedExtensionRecommendations); + super(); } protected async doActivate(): Promise { diff --git a/src/vs/workbench/contrib/extensions/browser/dynamicWorkspaceRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/dynamicWorkspaceRecommendations.ts index 7a3c0c2f784..87780840a9c 100644 --- a/src/vs/workbench/contrib/extensions/browser/dynamicWorkspaceRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/dynamicWorkspaceRecommendations.ts @@ -11,7 +11,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { isNonEmptyArray } from 'vs/base/common/arrays'; import { IWorkspaceTagsService } from 'vs/workbench/contrib/tags/common/workspaceTags'; import { isNumber } from 'vs/base/common/types'; -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { localize } from 'vs/nls'; @@ -30,7 +30,6 @@ export class DynamicWorkspaceRecommendations extends ExtensionRecommendations { get recommendations(): ReadonlyArray { return this._recommendations; } constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, @IExtensionTipsService private readonly extensionTipsService: IExtensionTipsService, @IWorkspaceTagsService private readonly workspaceTagsService: IWorkspaceTagsService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @@ -38,7 +37,7 @@ export class DynamicWorkspaceRecommendations extends ExtensionRecommendations { @ITelemetryService private readonly telemetryService: ITelemetryService, @IStorageService private readonly storageService: IStorageService, ) { - super(promptedExtensionRecommendations); + super(); } protected async doActivate(): Promise { diff --git a/src/vs/workbench/contrib/extensions/browser/exeBasedRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/exeBasedRecommendations.ts index 220d24318aa..cccecbcfe65 100644 --- a/src/vs/workbench/contrib/extensions/browser/exeBasedRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/exeBasedRecommendations.ts @@ -5,13 +5,14 @@ import { IExtensionTipsService, IExecutableBasedExtensionTip, IExtensionManagementService, ILocalExtension } from 'vs/platform/extensionManagement/common/extensionManagement'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { timeout } from 'vs/base/common/async'; import { localize } from 'vs/nls'; import { optional } from 'vs/platform/instantiation/common/instantiation'; import { basename } from 'vs/base/common/path'; import { ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { ITASExperimentService } from 'vs/workbench/services/experiment/common/experimentService'; +import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; type ExeExtensionRecommendationsClassification = { extensionId: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; @@ -31,13 +32,13 @@ export class ExeBasedRecommendations extends ExtensionRecommendations { private readonly tasExperimentService: ITASExperimentService | undefined; constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, @IExtensionTipsService private readonly extensionTipsService: IExtensionTipsService, @optional(ITASExperimentService) tasExperimentService: ITASExperimentService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService, ) { - super(promptedExtensionRecommendations); + super(); this.tasExperimentService = tasExperimentService; /* @@ -103,14 +104,9 @@ export class ExeBasedRecommendations extends ExtensionRecommendations { } private async promptImportantExeBasedRecommendations(recommendations: string[], importantExeBasedRecommendations: Map): Promise { - if (this.promptedExtensionRecommendations.hasToIgnoreRecommendationNotifications()) { + if (this.extensionRecommendationNotificationService.hasToIgnoreRecommendationNotifications()) { return; } - recommendations = this.promptedExtensionRecommendations.filterIgnoredOrNotAllowed(recommendations); - if (recommendations.length === 0) { - return; - } - const recommendationsByExe = new Map(); for (const extensionId of recommendations) { const tip = importantExeBasedRecommendations.get(extensionId); @@ -131,7 +127,7 @@ export class ExeBasedRecommendations extends ExtensionRecommendations { } 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.promptedExtensionRecommendations.promptImportantExtensionsInstallNotification(extensionIds, message, `@exe:"${tips[0].exeName}"`); + this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification(extensionIds, message, `@exe:"${tips[0].exeName}"`); } } diff --git a/src/vs/workbench/contrib/extensions/browser/experimentalRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/experimentalRecommendations.ts index 16dd2fa4e81..cc0dda953fb 100644 --- a/src/vs/workbench/contrib/extensions/browser/experimentalRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/experimentalRecommendations.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { isNonEmptyArray } from 'vs/base/common/arrays'; -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { IExperimentService, ExperimentActionType, ExperimentState } from 'vs/workbench/contrib/experiments/common/experimentService'; @@ -14,10 +14,9 @@ export class ExperimentalRecommendations extends ExtensionRecommendations { get recommendations(): ReadonlyArray { return this._recommendations; } constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, @IExperimentService private readonly experimentService: IExperimentService, ) { - super(promptedExtensionRecommendations); + super(); } /** diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 81328757acc..5749ad885ee 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -19,7 +19,7 @@ import { EditorPane } from 'vs/workbench/browser/parts/editor/editorPane'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; -import { IExtensionRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; +import { IExtensionIgnoredRecommendationsService, IExtensionRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { IExtensionManifest, IKeyBinding, IView, IViewContainer, ExtensionType } from 'vs/platform/extensions/common/extensions'; import { ResolvedKeybinding, KeyMod, KeyCode } from 'vs/base/common/keyCodes'; import { ExtensionsInput } from 'vs/workbench/contrib/extensions/common/extensionsInput'; @@ -189,6 +189,7 @@ export class ExtensionEditor extends EditorPane { @INotificationService private readonly notificationService: INotificationService, @IOpenerService private readonly openerService: IOpenerService, @IExtensionRecommendationsService private readonly extensionRecommendationsService: IExtensionRecommendationsService, + @IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService, @IStorageService storageService: IStorageService, @IExtensionService private readonly extensionService: IExtensionService, @IWorkbenchThemeService private readonly workbenchThemeService: IWorkbenchThemeService, @@ -471,36 +472,27 @@ export class ExtensionEditor extends EditorPane { this.transientDisposables.add(ignoreAction); this.transientDisposables.add(undoIgnoreAction); - const extRecommendations = this.extensionRecommendationsService.getAllRecommendationsWithReason(); - if (extRecommendations[extension.identifier.id.toLowerCase()]) { - ignoreAction.enabled = true; - template.subtext.textContent = extRecommendations[extension.identifier.id.toLowerCase()].reasonText; - show(template.subtextContainer); - } else if (this.extensionRecommendationsService.getIgnoredRecommendations().indexOf(extension.identifier.id.toLowerCase()) !== -1) { - undoIgnoreAction.enabled = true; - template.subtext.textContent = localize('recommendationHasBeenIgnored', "You have chosen not to receive recommendations for this extension."); - show(template.subtextContainer); - } - else { - template.subtext.textContent = ''; - } - - this.extensionRecommendationsService.onRecommendationChange(change => { - if (change.extensionId.toLowerCase() === extension.identifier.id.toLowerCase()) { - if (change.isRecommended) { - undoIgnoreAction.enabled = false; - const extRecommendations = this.extensionRecommendationsService.getAllRecommendationsWithReason(); - if (extRecommendations[extension.identifier.id.toLowerCase()]) { - ignoreAction.enabled = true; - template.subtext.textContent = extRecommendations[extension.identifier.id.toLowerCase()].reasonText; - } - } else { - undoIgnoreAction.enabled = true; - ignoreAction.enabled = false; - template.subtext.textContent = localize('recommendationHasBeenIgnored', "You have chosen not to receive recommendations for this extension."); - } + const updateRecommendationFn = () => { + const extRecommendations = this.extensionRecommendationsService.getAllRecommendationsWithReason(); + if (extRecommendations[extension.identifier.id.toLowerCase()]) { + ignoreAction.enabled = true; + undoIgnoreAction.enabled = false; + template.subtext.textContent = extRecommendations[extension.identifier.id.toLowerCase()].reasonText; + show(template.subtextContainer); + } else if (this.extensionIgnoredRecommendationsService.globalIgnoredRecommendations.indexOf(extension.identifier.id.toLowerCase()) !== -1) { + ignoreAction.enabled = false; + undoIgnoreAction.enabled = true; + template.subtext.textContent = localize('recommendationHasBeenIgnored', "You have chosen not to receive recommendations for this extension."); + show(template.subtextContainer); + } else { + ignoreAction.enabled = false; + undoIgnoreAction.enabled = false; + template.subtext.textContent = ''; + hide(template.subtextContainer); } - }); + }; + updateRecommendationFn(); + this.transientDisposables.add(this.extensionRecommendationsService.onDidChangeRecommendations(() => updateRecommendationFn())); this.transientDisposables.add(reloadAction.onDidChange(e => { if (e.tooltip) { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts new file mode 100644 index 00000000000..d88576ce36c --- /dev/null +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction } from 'vs/base/common/actions'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { localize } from 'vs/nls'; +import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; +import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { IStorageKeysSyncRegistryService } from 'vs/platform/userDataSync/common/storageKeys'; +import { SearchExtensionsAction } from 'vs/workbench/contrib/extensions/browser/extensionsActions'; +import { IExtension, IExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/common/extensions'; +import { EnablementState, IWorkbenchExtensionEnablementService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; +import { IExtensionIgnoredRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; + +interface IExtensionsConfiguration { + autoUpdate: boolean; + autoCheckUpdates: boolean; + ignoreRecommendations: boolean; + showRecommendationsOnlyOnDemand: boolean; + closeExtensionDetailsOnViewChange: boolean; +} + +type ExtensionRecommendationsNotificationClassification = { + userReaction: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; + extensionId?: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; +}; + +type ExtensionWorkspaceRecommendationsNotificationClassification = { + userReaction: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; +}; + +const ignoreImportantExtensionRecommendation = 'extensionsAssistant/importantRecommendationsIgnore'; +const ignoreWorkspaceRecommendationsStorageKey = 'extensionsAssistant/workspaceRecommendationsIgnore'; +const choiceNever = localize('neverShowAgain', "Don't Show Again"); + +export class ExtensionRecommendationNotificationService extends Disposable implements IExtensionRecommendationNotificationService { + + declare readonly _serviceBrand: undefined; + + // Ignored Important Recommendations + get ignoredRecommendations(): string[] { + return [...(JSON.parse(this.storageService.get(ignoreImportantExtensionRecommendation, StorageScope.GLOBAL, '[]')))].map(i => i.toLowerCase()); + } + + constructor( + @IConfigurationService private readonly configurationService: IConfigurationService, + @IStorageService private readonly storageService: IStorageService, + @INotificationService private readonly notificationService: INotificationService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, + @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, + @IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService, + @IStorageKeysSyncRegistryService storageKeysSyncRegistryService: IStorageKeysSyncRegistryService, + ) { + super(); + storageKeysSyncRegistryService.registerStorageKey({ key: ignoreImportantExtensionRecommendation, version: 1 }); + } + + hasToIgnoreRecommendationNotifications(): boolean { + const config = this.configurationService.getValue('extensions'); + return config.ignoreRecommendations || config.showRecommendationsOnlyOnDemand; + } + + async promptImportantExtensionsInstallNotification(extensionIds: string[], message: string, searchValue: string): Promise { + if (this.hasToIgnoreRecommendationNotifications()) { + return false; + } + + extensionIds = this.filterIgnoredOrNotAllowed(extensionIds); + if (!extensionIds.length) { + return false; + } + + const extensions = await this.getInstallableExtensions(extensionIds); + if (!extensions.length) { + return false; + } + + 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 }); + } + 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 }); + } + 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 }); + } + } + } + ); + + return true; + } + + async promptWorkspaceRecommendations(recommendations: string[]): Promise { + if (this.hasToIgnoreWorkspaceRecommendationNotifications()) { + return false; + } + + let installed = await this.extensionManagementService.getInstalled(); + installed = installed.filter(l => this.extensionEnablementService.getEnablementState(l) !== EnablementState.DisabledByExtensionKind); // Filter extensions disabled by kind + recommendations = recommendations.filter(extensionId => installed.every(local => !areSameExtensions({ id: extensionId }, local.identifier))); + + if (!recommendations.length) { + return false; + } + + const extensions = await this.getInstallableExtensions(recommendations); + if (!extensions.length) { + return false; + } + + const searchValue = '@recommended '; + this.notificationService.prompt( + Severity.Info, + localize('workspaceRecommended', "Do you want to install the recommended extensions for this repository?"), + [{ + label: localize('install', "Install"), + run: async () => { + this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'install' }); + await Promise.all(extensions.map(async extension => { + this.extensionsWorkbenchService.open(extension, { pinned: true }); + await this.extensionManagementService.installFromGallery(extension.gallery!); + })); + } + }, { + label: localize('showRecommendations', "Show Recommendations"), + run: async () => { + this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'show' }); + this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); + } + }, { + label: localize('neverShowAgain', "Don't Show Again"), + isSecondary: true, + run: () => { + this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'neverShowAgain' }); + this.storageService.store(ignoreWorkspaceRecommendationsStorageKey, true, StorageScope.WORKSPACE); + } + }], + { + sticky: true, + onCancel: () => { + this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'cancelled' }); + } + } + ); + + return true; + } + + private hasToIgnoreWorkspaceRecommendationNotifications(): boolean { + return this.hasToIgnoreRecommendationNotifications() || this.storageService.getBoolean(ignoreWorkspaceRecommendationsStorageKey, StorageScope.WORKSPACE, false); + } + + private async getInstallableExtensions(extensionIds: string[]): Promise { + const extensions: IExtension[] = []; + if (extensionIds.length) { + const pager = await this.extensionsWorkbenchService.queryGallery({ names: extensionIds, pageSize: extensionIds.length, source: 'install-recommendations' }, CancellationToken.None); + for (const extension of pager.firstPage) { + if (extension.gallery && (await this.extensionManagementService.canInstall(extension.gallery))) { + extensions.push(extension); + } + } + } + return extensions; + } + + private filterIgnoredOrNotAllowed(recommendationsToSuggest: string[]): string[] { + const ignoredRecommendations = [...this.extensionIgnoredRecommendationsService.ignoredRecommendations, ...this.ignoredRecommendations]; + return recommendationsToSuggest.filter(id => !ignoredRecommendations.includes(id)); + } + + private async runAction(action: IAction): Promise { + try { + await action.run(); + } finally { + action.dispose(); + } + } + + private addToImportantRecommendationsIgnore(id: string) { + const importantRecommendationsIgnoreList = JSON.parse(this.storageService.get(ignoreImportantExtensionRecommendation, StorageScope.GLOBAL, '[]')); + importantRecommendationsIgnoreList.push(id.toLowerCase()); + this.storageService.store(ignoreImportantExtensionRecommendation, JSON.stringify(importantRecommendationsIgnoreList), StorageScope.GLOBAL); + } + + private setIgnoreRecommendationsConfig(configVal: boolean) { + this.configurationService.updateValue('extensions.ignoreRecommendations', configVal, ConfigurationTarget.USER); + } +} diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendations.ts index d8f649fdf7c..5c6ee68a963 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendations.ts @@ -4,34 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from 'vs/base/common/lifecycle'; -import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; -import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { localize } from 'vs/nls'; -import { SearchExtensionsAction } from 'vs/workbench/contrib/extensions/browser/extensionsActions'; -import { EnablementState, IWorkbenchExtensionEnablementService } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; import { IExtensionRecommendationReson } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; -import { IExtensionsConfiguration, ConfigurationKey, IExtension, IExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/common/extensions'; -import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; -import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; -import { IStorageKeysSyncRegistryService } from 'vs/platform/userDataSync/common/storageKeys'; -import { IAction } from 'vs/base/common/actions'; -import { IExtensionManagementService } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { CancellationToken } from 'vs/base/common/cancellation'; -import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; - -type ExtensionRecommendationsNotificationClassification = { - userReaction: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; - extensionId?: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; -}; - -type ExtensionWorkspaceRecommendationsNotificationClassification = { - userReaction: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; -}; - -const ignoreWorkspaceRecommendationsStorageKey = 'extensionsAssistant/workspaceRecommendationsIgnore'; -const ignoreImportantExtensionRecommendation = 'extensionsAssistant/importantRecommendationsIgnore'; -const choiceNever = localize('neverShowAgain', "Don't Show Again"); export type ExtensionRecommendation = { readonly extensionId: string, @@ -43,12 +16,6 @@ export abstract class ExtensionRecommendations extends Disposable { readonly abstract recommendations: ReadonlyArray; protected abstract doActivate(): Promise; - constructor( - protected readonly promptedExtensionRecommendations: PromptedExtensionRecommendations, - ) { - super(); - } - private _activationPromise: Promise | null = null; get activated(): boolean { return this._activationPromise !== null; } activate(): Promise { @@ -59,193 +26,3 @@ export abstract class ExtensionRecommendations extends Disposable { } } - -export class PromptedExtensionRecommendations extends Disposable { - - constructor( - private readonly isExtensionAllowedToBeRecommended: (extensionId: string) => boolean, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @INotificationService private readonly notificationService: INotificationService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - @IStorageService private readonly storageService: IStorageService, - @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, - @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, - @IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService, - @IStorageKeysSyncRegistryService storageKeysSyncRegistryService: IStorageKeysSyncRegistryService, - ) { - super(); - storageKeysSyncRegistryService.registerStorageKey({ key: ignoreImportantExtensionRecommendation, version: 1 }); - } - - async promptImportantExtensionsInstallNotification(extensionIds: string[], message: string, searchValue: string): Promise { - if (this.hasToIgnoreRecommendationNotifications()) { - return; - } - - const extensions = await this.getInstallableExtensions(extensionIds); - if (!extensions.length) { - return; - } - - 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 }); - } - 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 }); - } - 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 }); - } - } - } - ); - } - - async promptWorkspaceRecommendations(recommendations: string[]): Promise { - if (this.hasToIgnoreWorkspaceRecommendationNotifications()) { - return; - } - - let installed = await this.extensionManagementService.getInstalled(); - installed = installed.filter(l => this.extensionEnablementService.getEnablementState(l) !== EnablementState.DisabledByExtensionKind); // Filter extensions disabled by kind - recommendations = recommendations.filter(extensionId => installed.every(local => !areSameExtensions({ id: extensionId }, local.identifier))); - - if (!recommendations.length) { - return; - } - - const extensions = await this.getInstallableExtensions(recommendations); - if (!extensions.length) { - return; - } - - const searchValue = '@recommended '; - this.notificationService.prompt( - Severity.Info, - localize('workspaceRecommended', "Do you want to install the recommended extensions for this repository?"), - [{ - label: localize('install', "Install"), - run: async () => { - this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'install' }); - await Promise.all(extensions.map(async extension => { - this.extensionsWorkbenchService.open(extension, { pinned: true }); - await this.extensionManagementService.installFromGallery(extension.gallery!); - })); - } - }, { - label: localize('showRecommendations', "Show Recommendations"), - run: async () => { - this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'show' }); - this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); - } - }, { - label: localize('neverShowAgain', "Don't Show Again"), - isSecondary: true, - run: () => { - this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'neverShowAgain' }); - this.storageService.store(ignoreWorkspaceRecommendationsStorageKey, true, StorageScope.WORKSPACE); - } - }], - { - sticky: true, - onCancel: () => { - this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'cancelled' }); - } - } - ); - } - - hasToIgnoreRecommendationNotifications(): boolean { - const config = this.configurationService.getValue(ConfigurationKey); - return config.ignoreRecommendations || config.showRecommendationsOnlyOnDemand; - } - - hasToIgnoreWorkspaceRecommendationNotifications(): boolean { - return this.hasToIgnoreRecommendationNotifications() || this.storageService.getBoolean(ignoreWorkspaceRecommendationsStorageKey, StorageScope.WORKSPACE, false); - } - - filterIgnoredOrNotAllowed(recommendationsToSuggest: string[]): string[] { - const importantRecommendationsIgnoreList = (JSON.parse(this.storageService.get(ignoreImportantExtensionRecommendation, StorageScope.GLOBAL, '[]'))).map(e => e.toLowerCase()); - return recommendationsToSuggest.filter(id => { - if (importantRecommendationsIgnoreList.indexOf(id) !== -1) { - return false; - } - if (!this.isExtensionAllowedToBeRecommended(id)) { - return false; - } - return true; - }); - } - - private async getInstallableExtensions(extensionIds: string[]): Promise { - const extensions: IExtension[] = []; - if (extensionIds.length) { - const pager = await this.extensionsWorkbenchService.queryGallery({ names: extensionIds, pageSize: extensionIds.length, source: 'install-recommendations' }, CancellationToken.None); - for (const extension of pager.firstPage) { - if (extension.gallery && (await this.extensionManagementService.canInstall(extension.gallery))) { - extensions.push(extension); - } - } - } - return extensions; - } - - private async runAction(action: IAction): Promise { - try { - await action.run(); - } finally { - action.dispose(); - } - } - - private addToImportantRecommendationsIgnore(id: string) { - const importantRecommendationsIgnoreList = JSON.parse(this.storageService.get(ignoreImportantExtensionRecommendation, StorageScope.GLOBAL, '[]')); - importantRecommendationsIgnoreList.push(id.toLowerCase()); - this.storageService.store(ignoreImportantExtensionRecommendation, JSON.stringify(importantRecommendationsIgnoreList), StorageScope.GLOBAL); - } - - private setIgnoreRecommendationsConfig(configVal: boolean) { - this.configurationService.updateValue('extensions.ignoreRecommendations', configVal, ConfigurationTarget.USER); - } - -} - diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts index d49c86b3cf2..02e53565da8 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationsService.ts @@ -5,8 +5,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { IExtensionManagementService, IExtensionGalleryService, InstallOperation, DidInstallExtensionEvent } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { IExtensionRecommendationsService, ExtensionRecommendationReason, RecommendationChangeNotification } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; -import { IStorageService, StorageScope, IWorkspaceStorageChangeEvent } from 'vs/platform/storage/common/storage'; +import { IExtensionRecommendationsService, ExtensionRecommendationReason, IExtensionIgnoredRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ShowRecommendationsOnlyOnDemandKey } from 'vs/workbench/contrib/extensions/common/extensions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -21,23 +20,19 @@ import { ExperimentalRecommendations } from 'vs/workbench/contrib/extensions/bro import { WorkspaceRecommendations } from 'vs/workbench/contrib/extensions/browser/workspaceRecommendations'; import { FileBasedRecommendations } from 'vs/workbench/contrib/extensions/browser/fileBasedRecommendations'; import { KeymapRecommendations } from 'vs/workbench/contrib/extensions/browser/keymapRecommendations'; -import { ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; -import { IStorageKeysSyncRegistryService } from 'vs/platform/userDataSync/common/storageKeys'; +import { ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { ConfigBasedRecommendations } from 'vs/workbench/contrib/extensions/browser/configBasedRecommendations'; +import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; type IgnoreRecommendationClassification = { recommendationReason: { classification: 'SystemMetaData', purpose: 'FeatureInsight', isMeasurement: true }; extensionId: { classification: 'PublicNonPersonalData', purpose: 'FeatureInsight' }; }; -const ignoredRecommendationsStorageKey = 'extensionsAssistant/ignored_recommendations'; - export class ExtensionRecommendationsService extends Disposable implements IExtensionRecommendationsService { declare readonly _serviceBrand: undefined; - private readonly promptedExtensionRecommendations: PromptedExtensionRecommendations; - // Recommendations private readonly fileBasedRecommendations: FileBasedRecommendations; private readonly workspaceRecommendations: WorkspaceRecommendations; @@ -47,39 +42,32 @@ export class ExtensionRecommendationsService extends Disposable implements IExte private readonly dynamicWorkspaceRecommendations: DynamicWorkspaceRecommendations; private readonly keymapRecommendations: KeymapRecommendations; - // Ignored Recommendations - private globallyIgnoredRecommendations: string[] = []; - public readonly activationPromise: Promise; private sessionSeed: number; - private readonly _onRecommendationChange = this._register(new Emitter()); - onRecommendationChange: Event = this._onRecommendationChange.event; + private _onDidChangeRecommendations = this._register(new Emitter()); + readonly onDidChangeRecommendations = this._onDidChangeRecommendations.event; constructor( @IInstantiationService instantiationService: IInstantiationService, @ILifecycleService private readonly lifecycleService: ILifecycleService, - @IStorageKeysSyncRegistryService storageKeysSyncRegistryService: IStorageKeysSyncRegistryService, @IExtensionGalleryService private readonly galleryService: IExtensionGalleryService, - @IStorageService private readonly storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IEnvironmentService private readonly environmentService: IEnvironmentService, @IExtensionManagementService private readonly extensionManagementService: IExtensionManagementService, + @IExtensionIgnoredRecommendationsService private readonly extensionRecommendationsManagementService: IExtensionIgnoredRecommendationsService, + @IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService, ) { super(); - storageKeysSyncRegistryService.registerStorageKey({ key: ignoredRecommendationsStorageKey, version: 1 }); - - const isExtensionAllowedToBeRecommended = (extensionId: string) => this.isExtensionAllowedToBeRecommended(extensionId); - this.promptedExtensionRecommendations = instantiationService.createInstance(PromptedExtensionRecommendations, isExtensionAllowedToBeRecommended); - this.workspaceRecommendations = instantiationService.createInstance(WorkspaceRecommendations, this.promptedExtensionRecommendations); - this.fileBasedRecommendations = instantiationService.createInstance(FileBasedRecommendations, this.promptedExtensionRecommendations); - this.experimentalRecommendations = instantiationService.createInstance(ExperimentalRecommendations, this.promptedExtensionRecommendations); - this.configBasedRecommendations = instantiationService.createInstance(ConfigBasedRecommendations, this.promptedExtensionRecommendations); - this.exeBasedRecommendations = instantiationService.createInstance(ExeBasedRecommendations, this.promptedExtensionRecommendations); - this.dynamicWorkspaceRecommendations = instantiationService.createInstance(DynamicWorkspaceRecommendations, this.promptedExtensionRecommendations); - this.keymapRecommendations = instantiationService.createInstance(KeymapRecommendations, this.promptedExtensionRecommendations); + this.workspaceRecommendations = instantiationService.createInstance(WorkspaceRecommendations); + this.fileBasedRecommendations = instantiationService.createInstance(FileBasedRecommendations); + this.experimentalRecommendations = instantiationService.createInstance(ExperimentalRecommendations); + this.configBasedRecommendations = instantiationService.createInstance(ConfigBasedRecommendations); + this.exeBasedRecommendations = instantiationService.createInstance(ExeBasedRecommendations); + this.dynamicWorkspaceRecommendations = instantiationService.createInstance(DynamicWorkspaceRecommendations); + this.keymapRecommendations = instantiationService.createInstance(KeymapRecommendations); if (!this.isEnabled()) { this.sessionSeed = 0; @@ -88,13 +76,11 @@ export class ExtensionRecommendationsService extends Disposable implements IExte } this.sessionSeed = +new Date(); - this.globallyIgnoredRecommendations = this.getCachedIgnoredRecommendations(); // Activation this.activationPromise = this.activate(); this._register(this.extensionManagementService.onDidInstallExtension(e => this.onDidInstallExtension(e))); - this._register(this.storageService.onDidChangeStorage(e => this.onDidStorageChange(e))); } private async activate(): Promise { @@ -114,6 +100,16 @@ export class ExtensionRecommendationsService extends Disposable implements IExte }) ]); + this._register(this.extensionRecommendationsManagementService.onDidChangeIgnoredRecommendations(() => this._onDidChangeRecommendations.fire())); + this._register(this.extensionRecommendationsManagementService.onDidChangeGlobalIgnoredRecommendation(({ extensionId, isRecommended }) => { + if (!isRecommended) { + const reason = this.getAllRecommendationsWithReason()[extensionId]; + if (reason && reason.reasonId) { + this.telemetryService.publicLog2<{ extensionId: string, recommendationReason: ExtensionRecommendationReason }, IgnoreRecommendationClassification>('extensionsRecommendations:ignoreRecommendation', { extensionId, recommendationReason: reason.reasonId }); + } + } + })); + await this.promptWorkspaceRecommendations(); this._register(Event.any(this.workspaceRecommendations.onDidChangeRecommendations, this.configBasedRecommendations.onDidChangeRecommendations)(() => this.promptWorkspaceRecommendations())); } @@ -217,29 +213,6 @@ export class ExtensionRecommendationsService extends Disposable implements IExte return this.toExtensionRecommendations(this.fileBasedRecommendations.recommendations); } - getIgnoredRecommendations(): ReadonlyArray { - return this.globallyIgnoredRecommendations; - } - - toggleIgnoredRecommendation(extensionId: string, shouldIgnore: boolean) { - extensionId = extensionId.toLowerCase(); - const ignored = this.globallyIgnoredRecommendations.indexOf(extensionId) !== -1; - if (ignored === shouldIgnore) { - return; - } - - if (shouldIgnore) { - const reason = this.getAllRecommendationsWithReason()[extensionId]; - if (reason && reason.reasonId) { - this.telemetryService.publicLog2<{ extensionId: string, recommendationReason: ExtensionRecommendationReason }, IgnoreRecommendationClassification>('extensionsRecommendations:ignoreRecommendation', { extensionId, recommendationReason: reason.reasonId }); - } - } - - this.globallyIgnoredRecommendations = shouldIgnore ? [...this.globallyIgnoredRecommendations, extensionId] : this.globallyIgnoredRecommendations.filter(id => id !== extensionId); - this.storeCachedIgnoredRecommendations(this.globallyIgnoredRecommendations); - this._onRecommendationChange.fire({ extensionId, isRecommended: !shouldIgnore }); - } - private onDidInstallExtension(e: DidInstallExtensionEvent): void { if (e.gallery && e.operation === InstallOperation.Install) { const extRecommendations = this.getAllRecommendationsWithReason() || {}; @@ -265,12 +238,8 @@ export class ExtensionRecommendationsService extends Disposable implements IExte return extensionIds; } - private isExtensionAllowedToBeRecommended(id: string): boolean { - const allIgnoredRecommendations = [ - ...this.globallyIgnoredRecommendations, - ...this.workspaceRecommendations.ignoredRecommendations - ]; - return allIgnoredRecommendations.indexOf(id.toLowerCase()) === -1; + private isExtensionAllowedToBeRecommended(extensionId: string): boolean { + return !this.extensionRecommendationsManagementService.ignoredRecommendations.includes(extensionId.toLowerCase()); } private async promptWorkspaceRecommendations(): Promise { @@ -279,49 +248,10 @@ export class ExtensionRecommendationsService extends Disposable implements IExte .filter(extensionId => this.isExtensionAllowedToBeRecommended(extensionId)); if (allowedRecommendations.length) { - await this.promptedExtensionRecommendations.promptWorkspaceRecommendations(allowedRecommendations); + await this.extensionRecommendationNotificationService.promptWorkspaceRecommendations(allowedRecommendations); } } - private onDidStorageChange(e: IWorkspaceStorageChangeEvent): void { - if (e.key === ignoredRecommendationsStorageKey && e.scope === StorageScope.GLOBAL - && this.ignoredRecommendationsValue !== this.getStoredIgnoredRecommendationsValue() /* This checks if current window changed the value or not */) { - this._ignoredRecommendationsValue = undefined; - this.globallyIgnoredRecommendations = this.getCachedIgnoredRecommendations(); - } - } - private getCachedIgnoredRecommendations(): string[] { - const ignoredRecommendations: string[] = JSON.parse(this.ignoredRecommendationsValue); - return ignoredRecommendations.map(e => e.toLowerCase()); - } - - private storeCachedIgnoredRecommendations(ignoredRecommendations: string[]): void { - this.ignoredRecommendationsValue = JSON.stringify(ignoredRecommendations); - } - - private _ignoredRecommendationsValue: string | undefined; - private get ignoredRecommendationsValue(): string { - if (!this._ignoredRecommendationsValue) { - this._ignoredRecommendationsValue = this.getStoredIgnoredRecommendationsValue(); - } - - return this._ignoredRecommendationsValue; - } - - private set ignoredRecommendationsValue(ignoredRecommendationsValue: string) { - if (this.ignoredRecommendationsValue !== ignoredRecommendationsValue) { - this._ignoredRecommendationsValue = ignoredRecommendationsValue; - this.setStoredIgnoredRecommendationsValue(ignoredRecommendationsValue); - } - } - - private getStoredIgnoredRecommendationsValue(): string { - return this.storageService.get(ignoredRecommendationsStorageKey, StorageScope.GLOBAL, '[]'); - } - - private setStoredIgnoredRecommendationsValue(value: string): void { - this.storageService.store(ignoredRecommendationsStorageKey, value, StorageScope.GLOBAL); - } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 655685298ff..a6f01f0dfc3 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -56,9 +56,12 @@ import { ExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/brow import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { WorkbenchStateContext } from 'vs/workbench/browser/contextkeys'; import { CATEGORIES } from 'vs/workbench/common/actions'; +import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; +import { ExtensionRecommendationNotificationService } from 'vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService'; // Singletons registerSingleton(IExtensionsWorkbenchService, ExtensionsWorkbenchService); +registerSingleton(IExtensionRecommendationNotificationService, ExtensionRecommendationNotificationService); registerSingleton(IExtensionRecommendationsService, ExtensionRecommendationsService); Registry.as(OutputExtensions.OutputChannels) diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 12a8fb36063..17b2b8a5093 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -16,7 +16,7 @@ import { IExtension, ExtensionState, IExtensionsWorkbenchService, VIEWLET_ID, IE import { ExtensionsConfigurationInitialContent } from 'vs/workbench/contrib/extensions/common/extensionsFileTemplate'; import { IGalleryExtension, IExtensionGalleryService, INSTALL_ERROR_MALICIOUS, INSTALL_ERROR_INCOMPATIBLE, IGalleryExtensionVersion, ILocalExtension, INSTALL_ERROR_NOT_SUPPORTED } from 'vs/platform/extensionManagement/common/extensionManagement'; import { IWorkbenchExtensionEnablementService, EnablementState, IExtensionManagementServerService, IExtensionManagementServer } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; -import { IExtensionRecommendationsService, IExtensionsConfigContent } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; +import { IExtensionIgnoredRecommendationsService, IExtensionsConfigContent } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { ExtensionType, ExtensionIdentifier, IExtensionDescription, IExtensionManifest, isLanguagePackExtension } from 'vs/platform/extensions/common/extensions'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; @@ -1912,7 +1912,7 @@ export class IgnoreExtensionRecommendationAction extends Action { constructor( private readonly extension: IExtension, - @IExtensionRecommendationsService private readonly extensionsTipsService: IExtensionRecommendationsService, + @IExtensionIgnoredRecommendationsService private readonly extensionRecommendationsManagementService: IExtensionIgnoredRecommendationsService, ) { super(IgnoreExtensionRecommendationAction.ID, 'Ignore Recommendation'); @@ -1922,7 +1922,7 @@ export class IgnoreExtensionRecommendationAction extends Action { } public run(): Promise { - this.extensionsTipsService.toggleIgnoredRecommendation(this.extension.identifier.id, true); + this.extensionRecommendationsManagementService.toggleGlobalIgnoredRecommendation(this.extension.identifier.id, true); return Promise.resolve(); } } @@ -1935,7 +1935,7 @@ export class UndoIgnoreExtensionRecommendationAction extends Action { constructor( private readonly extension: IExtension, - @IExtensionRecommendationsService private readonly extensionsTipsService: IExtensionRecommendationsService, + @IExtensionIgnoredRecommendationsService private readonly extensionRecommendationsManagementService: IExtensionIgnoredRecommendationsService, ) { super(UndoIgnoreExtensionRecommendationAction.ID, 'Undo'); @@ -1945,7 +1945,7 @@ export class UndoIgnoreExtensionRecommendationAction extends Action { } public run(): Promise { - this.extensionsTipsService.toggleIgnoredRecommendation(this.extension.identifier.id, false); + this.extensionRecommendationsManagementService.toggleGlobalIgnoredRecommendation(this.extension.identifier.id, false); return Promise.resolve(); } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts index 936a75ab483..c5979ca03c3 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViews.ts @@ -955,7 +955,7 @@ export class DefaultRecommendedExtensionsView extends ExtensionsListView { renderBody(container: HTMLElement): void { super.renderBody(container); - this._register(this.extensionRecommendationsService.onRecommendationChange(() => { + this._register(this.extensionRecommendationsService.onDidChangeRecommendations(() => { this.show(''); })); } @@ -980,7 +980,7 @@ export class RecommendedExtensionsView extends ExtensionsListView { renderBody(container: HTMLElement): void { super.renderBody(container); - this._register(this.extensionRecommendationsService.onRecommendationChange(() => { + this._register(this.extensionRecommendationsService.onDidChangeRecommendations(() => { this.show(''); })); } @@ -997,7 +997,7 @@ export class WorkspaceRecommendedExtensionsView extends ExtensionsListView { renderBody(container: HTMLElement): void { super.renderBody(container); - this._register(this.extensionRecommendationsService.onRecommendationChange(() => this.show(this.recommendedExtensionsQuery))); + this._register(this.extensionRecommendationsService.onDidChangeRecommendations(() => this.show(this.recommendedExtensionsQuery))); this._register(this.contextService.onDidChangeWorkbenchState(() => this.show(this.recommendedExtensionsQuery))); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts index 330e56ed82c..8718d0b9b03 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts @@ -199,7 +199,7 @@ export class RecommendationWidget extends ExtensionWidget { super(); this.render(); this._register(toDisposable(() => this.clear())); - this._register(this.extensionRecommendationsService.onRecommendationChange(() => this.render())); + this._register(this.extensionRecommendationsService.onDidChangeRecommendations(() => this.render())); } private clear(): void { diff --git a/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts index b66f07f64be..7b03d99e1e2 100644 --- a/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/fileBasedRecommendations.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { EnablementState } from 'vs/workbench/services/extensionManagement/common/extensionManagement'; -import { ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; +import { ExtensionRecommendationReason, IExtensionIgnoredRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { IExtensionsViewPaneContainer, IExtensionsWorkbenchService, IExtension } from 'vs/workbench/contrib/extensions/common/extensions'; import { CancellationToken } from 'vs/base/common/cancellation'; import { localize } from 'vs/nls'; @@ -25,6 +25,7 @@ import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { IModelService } from 'vs/editor/common/services/modelService'; import { setImmediate } from 'vs/base/common/platform'; import { IModeService } from 'vs/editor/common/services/modeService'; +import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; type FileExtensionSuggestionClassification = { userReaction: { classification: 'SystemMetaData', purpose: 'FeatureInsight' }; @@ -81,7 +82,6 @@ export class FileBasedRecommendations extends ExtensionRecommendations { } constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IExtensionService private readonly extensionService: IExtensionService, @IViewletService private readonly viewletService: IViewletService, @@ -91,8 +91,10 @@ export class FileBasedRecommendations extends ExtensionRecommendations { @INotificationService private readonly notificationService: INotificationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IStorageService private readonly storageService: IStorageService, + @IExtensionRecommendationNotificationService private readonly extensionRecommendationNotificationService: IExtensionRecommendationNotificationService, + @IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService, ) { - super(promptedExtensionRecommendations); + super(); if (productService.extensionTips) { forEach(productService.extensionTips, ({ key, value }) => this.extensionTips.set(key.toLowerCase(), value)); @@ -204,7 +206,7 @@ export class FileBasedRecommendations extends ExtensionRecommendations { this.storeCachedRecommendations(); - if (this.promptedExtensionRecommendations.hasToIgnoreRecommendationNotifications()) { + if (this.extensionRecommendationNotificationService.hasToIgnoreRecommendationNotifications()) { return; } @@ -229,7 +231,7 @@ export class FileBasedRecommendations extends ExtensionRecommendations { private async promptRecommendedExtensionForFileType(name: string, recommendations: string[], installed: IExtension[]): Promise { - recommendations = this.promptedExtensionRecommendations.filterIgnoredOrNotAllowed(recommendations); + recommendations = this.filterIgnoredOrNotAllowed(recommendations); if (recommendations.length === 0) { return false; } @@ -245,7 +247,7 @@ export class FileBasedRecommendations extends ExtensionRecommendations { return false; } - this.promptedExtensionRecommendations.promptImportantExtensionsInstallNotification([extensionId], localize('reallyRecommended', "Do you want to install the recommended extensions for {0}?", name), `@id:${extensionId}`); + this.extensionRecommendationNotificationService.promptImportantExtensionsInstallNotification([extensionId], localize('reallyRecommended', "Do you want to install the recommended extensions for {0}?", name), `@id:${extensionId}`); return true; } @@ -301,6 +303,11 @@ export class FileBasedRecommendations extends ExtensionRecommendations { ); } + private filterIgnoredOrNotAllowed(recommendationsToSuggest: string[]): string[] { + const ignoredRecommendations = [...this.extensionIgnoredRecommendationsService.ignoredRecommendations, ...this.extensionRecommendationNotificationService.ignoredRecommendations]; + return recommendationsToSuggest.filter(id => !ignoredRecommendations.includes(id)); + } + private filterInstalled(recommendationsToSuggest: string[], installed: IExtension[]): string[] { const installedExtensionsIds = installed.reduce((result, i) => { if (i.enablementState !== EnablementState.DisabledByExtensionKind) { diff --git a/src/vs/workbench/contrib/extensions/browser/keymapRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/keymapRecommendations.ts index d40178620d3..a40eed0f23f 100644 --- a/src/vs/workbench/contrib/extensions/browser/keymapRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/keymapRecommendations.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { IProductService } from 'vs/platform/product/common/productService'; import { ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; @@ -13,10 +13,9 @@ export class KeymapRecommendations extends ExtensionRecommendations { get recommendations(): ReadonlyArray { return this._recommendations; } constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, @IProductService private readonly productService: IProductService, ) { - super(promptedExtensionRecommendations); + super(); } protected async doActivate(): Promise { diff --git a/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts b/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts index 0a8d055a3ad..231aa8f4b47 100644 --- a/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts +++ b/src/vs/workbench/contrib/extensions/browser/workspaceRecommendations.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { EXTENSION_IDENTIFIER_PATTERN, IExtensionGalleryService } from 'vs/platform/extensionManagement/common/extensionManagement'; -import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; import { distinct, flatten } from 'vs/base/common/arrays'; -import { ExtensionRecommendations, ExtensionRecommendation, PromptedExtensionRecommendations } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; +import { ExtensionRecommendations, ExtensionRecommendation } from 'vs/workbench/contrib/extensions/browser/extensionRecommendations'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IExtensionsConfigContent, ExtensionRecommendationReason } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; import { ILogService } from 'vs/platform/log/common/log'; @@ -27,19 +26,17 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { get ignoredRecommendations(): ReadonlyArray { return this._ignoredRecommendations; } constructor( - promptedExtensionRecommendations: PromptedExtensionRecommendations, - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IWorkpsaceExtensionsConfigService private readonly workpsaceExtensionsConfigService: IWorkpsaceExtensionsConfigService, @IExtensionGalleryService private readonly galleryService: IExtensionGalleryService, @ILogService private readonly logService: ILogService, @INotificationService private readonly notificationService: INotificationService, ) { - super(promptedExtensionRecommendations); + super(); } protected async doActivate(): Promise { await this.fetch(); - this._register(this.contextService.onDidChangeWorkspaceFolders(e => this.onWorkspaceFoldersChanged(e))); + this._register(this.workpsaceExtensionsConfigService.onDidChangeExtensionsConfigs(() => this.onDidChangeExtensionsConfigs())); } /** @@ -116,14 +113,12 @@ export class WorkspaceRecommendations extends ExtensionRecommendations { return { validRecommendations: validExtensions, invalidRecommendations: invalidExtensions, message }; } - private async onWorkspaceFoldersChanged(event: IWorkspaceFoldersChangeEvent): Promise { - if (event.added.length) { - const oldWorkspaceRecommended = this._recommendations; - await this.fetch(); - // Suggest only if at least one of the newly added recommendations was not suggested before - if (this._recommendations.some(current => oldWorkspaceRecommended.every(old => current.extensionId !== old.extensionId))) { - this._onDidChangeRecommendations.fire(); - } + private async onDidChangeExtensionsConfigs(): Promise { + const oldWorkspaceRecommended = this._recommendations; + await this.fetch(); + // Suggest only if at least one of the newly added recommendations was not suggested before + if (this._recommendations.some(current => oldWorkspaceRecommended.every(old => current.extensionId !== old.extensionId))) { + this._onDidChangeRecommendations.fire(); } } diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts index 4f968a8c129..4b96ba059e7 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionRecommendationsService.test.ts @@ -59,6 +59,10 @@ import { IStorageKeysSyncRegistryService, StorageKeysSyncRegistryService } from import { ExtensionsWorkbenchService } from 'vs/workbench/contrib/extensions/browser/extensionsWorkbenchService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; import { IWorkpsaceExtensionsConfigService, WorkspaceExtensionsConfigService } from 'vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig'; +import { IExtensionIgnoredRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; +import { ExtensionIgnoredRecommendationsService } from 'vs/workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService'; +import { IExtensionRecommendationNotificationService } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; +import { ExtensionRecommendationNotificationService } from 'vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService'; const mockExtensionGallery: IGalleryExtension[] = [ aGalleryExtension('MockExtension1', { @@ -303,6 +307,8 @@ suite('ExtensionRecommendationsService Test', () => { workspaceService = new TestContextService(myWorkspace); instantiationService.stub(IWorkspaceContextService, workspaceService); instantiationService.stub(IWorkpsaceExtensionsConfigService, instantiationService.createInstance(WorkspaceExtensionsConfigService)); + instantiationService.stub(IExtensionIgnoredRecommendationsService, instantiationService.createInstance(ExtensionIgnoredRecommendationsService)); + instantiationService.stub(IExtensionRecommendationNotificationService, instantiationService.createInstance(ExtensionRecommendationNotificationService)); const fileService = new FileService(new NullLogService()); fileService.registerProvider(Schemas.file, new DiskFileSystemProvider(new NullLogService())); instantiationService.stub(IFileService, fileService); @@ -426,73 +432,73 @@ suite('ExtensionRecommendationsService Test', () => { }); }); - test('ExtensionRecommendationsService: Able to retrieve collection of all ignored recommendations', () => { + test('ExtensionRecommendationsService: Able to retrieve collection of all ignored recommendations', async () => { + const storageService = instantiationService.get(IStorageService); const workspaceIgnoredRecommendations = ['ms-dotnettools.csharp']; // ignore a stored recommendation and a workspace recommendation. const storedRecommendations = '["ms-dotnettools.csharp", "ms-python.python"]'; const globallyIgnoredRecommendations = '["mockpublisher2.mockextension2"]'; // ignore a workspace recommendation. - instantiationService.get(IStorageService).store('extensionsAssistant/workspaceRecommendationsIgnore', true, StorageScope.WORKSPACE); - instantiationService.get(IStorageService).store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL); - instantiationService.get(IStorageService).store('extensionsAssistant/ignored_recommendations', globallyIgnoredRecommendations, StorageScope.GLOBAL); + storageService.store('extensionsAssistant/workspaceRecommendationsIgnore', true, StorageScope.WORKSPACE); + storageService.store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL); + storageService.store('extensionsAssistant/ignored_recommendations', globallyIgnoredRecommendations, StorageScope.GLOBAL); - return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions, workspaceIgnoredRecommendations).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); - return testObject.activationPromise.then(() => { - const recommendations = testObject.getAllRecommendationsWithReason(); - assert.ok(recommendations['ms-python.python']); + await setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions, workspaceIgnoredRecommendations); + testObject = instantiationService.createInstance(ExtensionRecommendationsService); + await testObject.activationPromise; - assert.ok(!recommendations['mockpublisher2.mockextension2']); - assert.ok(!recommendations['ms-dotnettools.csharp']); - }); - }); + const recommendations = testObject.getAllRecommendationsWithReason(); + assert.ok(recommendations['ms-python.python']); + assert.ok(!recommendations['mockpublisher2.mockextension2']); + assert.ok(!recommendations['ms-dotnettools.csharp']); }); - test('ExtensionRecommendationsService: Able to dynamically ignore/unignore global recommendations', () => { + test('ExtensionRecommendationsService: Able to dynamically ignore/unignore global recommendations', async () => { + const storageService = instantiationService.get(IStorageService); + const storedRecommendations = '["ms-dotnettools.csharp", "ms-python.python"]'; const globallyIgnoredRecommendations = '["mockpublisher2.mockextension2"]'; // ignore a workspace recommendation. - instantiationService.get(IStorageService).store('extensionsAssistant/workspaceRecommendationsIgnore', true, StorageScope.WORKSPACE); - instantiationService.get(IStorageService).store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL); - instantiationService.get(IStorageService).store('extensionsAssistant/ignored_recommendations', globallyIgnoredRecommendations, StorageScope.GLOBAL); + storageService.store('extensionsAssistant/workspaceRecommendationsIgnore', true, StorageScope.WORKSPACE); + storageService.store('extensionsAssistant/recommendations', storedRecommendations, StorageScope.GLOBAL); + storageService.store('extensionsAssistant/ignored_recommendations', globallyIgnoredRecommendations, StorageScope.GLOBAL); - return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { - testObject = instantiationService.createInstance(ExtensionRecommendationsService); - return testObject.activationPromise.then(() => { - const recommendations = testObject.getAllRecommendationsWithReason(); - assert.ok(recommendations['ms-python.python']); - assert.ok(recommendations['mockpublisher1.mockextension1']); + await setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions); + const extensionIgnoredRecommendationsService = instantiationService.get(IExtensionIgnoredRecommendationsService); + testObject = instantiationService.createInstance(ExtensionRecommendationsService); + await testObject.activationPromise; - assert.ok(!recommendations['mockpublisher2.mockextension2']); + let recommendations = testObject.getAllRecommendationsWithReason(); + assert.ok(recommendations['ms-python.python']); + assert.ok(recommendations['mockpublisher1.mockextension1']); + assert.ok(!recommendations['mockpublisher2.mockextension2']); - return testObject.toggleIgnoredRecommendation('mockpublisher1.mockextension1', true); - }).then(() => { - const recommendations = testObject.getAllRecommendationsWithReason(); - assert.ok(recommendations['ms-python.python']); + extensionIgnoredRecommendationsService.toggleGlobalIgnoredRecommendation('mockpublisher1.mockextension1', true); - assert.ok(!recommendations['mockpublisher1.mockextension1']); - assert.ok(!recommendations['mockpublisher2.mockextension2']); + recommendations = testObject.getAllRecommendationsWithReason(); + assert.ok(recommendations['ms-python.python']); + assert.ok(!recommendations['mockpublisher1.mockextension1']); + assert.ok(!recommendations['mockpublisher2.mockextension2']); - return testObject.toggleIgnoredRecommendation('mockpublisher1.mockextension1', false); - }).then(() => { - const recommendations = testObject.getAllRecommendationsWithReason(); - assert.ok(recommendations['ms-python.python']); + extensionIgnoredRecommendationsService.toggleGlobalIgnoredRecommendation('mockpublisher1.mockextension1', false); - assert.ok(recommendations['mockpublisher1.mockextension1']); - assert.ok(!recommendations['mockpublisher2.mockextension2']); - }); - }); + recommendations = testObject.getAllRecommendationsWithReason(); + assert.ok(recommendations['ms-python.python']); + assert.ok(recommendations['mockpublisher1.mockextension1']); + assert.ok(!recommendations['mockpublisher2.mockextension2']); }); test('test global extensions are modified and recommendation change event is fired when an extension is ignored', async () => { + const storageService = instantiationService.get(IStorageService); const changeHandlerTarget = sinon.spy(); const ignoredExtensionId = 'Some.Extension'; - instantiationService.get(IStorageService).store('extensionsAssistant/workspaceRecommendationsIgnore', true, StorageScope.WORKSPACE); - instantiationService.get(IStorageService).store('extensionsAssistant/ignored_recommendations', '["ms-vscode.vscode"]', StorageScope.GLOBAL); + storageService.store('extensionsAssistant/workspaceRecommendationsIgnore', true, StorageScope.WORKSPACE); + storageService.store('extensionsAssistant/ignored_recommendations', '["ms-vscode.vscode"]', StorageScope.GLOBAL); await setUpFolderWorkspace('myFolder', []); testObject = instantiationService.createInstance(ExtensionRecommendationsService); - testObject.onRecommendationChange(changeHandlerTarget); - testObject.toggleIgnoredRecommendation(ignoredExtensionId, true); + const extensionIgnoredRecommendationsService = instantiationService.get(IExtensionIgnoredRecommendationsService); + extensionIgnoredRecommendationsService.onDidChangeGlobalIgnoredRecommendation(changeHandlerTarget); + extensionIgnoredRecommendationsService.toggleGlobalIgnoredRecommendation(ignoredExtensionId, true); await testObject.activationPromise; assert.ok(changeHandlerTarget.calledOnce); diff --git a/src/vs/workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService.ts b/src/vs/workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService.ts new file mode 100644 index 00000000000..ce9782b3a20 --- /dev/null +++ b/src/vs/workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { distinct } from 'vs/base/common/arrays'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { IStorageService, IWorkspaceStorageChangeEvent, StorageScope } from 'vs/platform/storage/common/storage'; +import { IStorageKeysSyncRegistryService } from 'vs/platform/userDataSync/common/storageKeys'; +import { IExtensionIgnoredRecommendationsService, IgnoredRecommendationChangeNotification } from 'vs/workbench/services/extensionRecommendations/common/extensionRecommendations'; +import { IWorkpsaceExtensionsConfigService } from 'vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig'; + +const ignoredRecommendationsStorageKey = 'extensionsAssistant/ignored_recommendations'; + +export class ExtensionIgnoredRecommendationsService extends Disposable implements IExtensionIgnoredRecommendationsService { + + declare readonly _serviceBrand: undefined; + + private _onDidChangeIgnoredRecommendations = this._register(new Emitter()); + readonly onDidChangeIgnoredRecommendations = this._onDidChangeIgnoredRecommendations.event; + + // Global Ignored Recommendations + private _globalIgnoredRecommendations: string[] = []; + get globalIgnoredRecommendations(): string[] { return [...this._globalIgnoredRecommendations]; } + private _onDidChangeGlobalIgnoredRecommendation = this._register(new Emitter()); + readonly onDidChangeGlobalIgnoredRecommendation = this._onDidChangeGlobalIgnoredRecommendation.event; + + // Ignored Workspace Recommendations + private ignoredWorkspaceRecommendations: string[] = []; + + get ignoredRecommendations(): string[] { return distinct([...this.globalIgnoredRecommendations, ...this.ignoredWorkspaceRecommendations]); } + + constructor( + @IWorkpsaceExtensionsConfigService private readonly workpsaceExtensionsConfigService: IWorkpsaceExtensionsConfigService, + @IStorageService private readonly storageService: IStorageService, + @IStorageKeysSyncRegistryService storageKeysSyncRegistryService: IStorageKeysSyncRegistryService, + ) { + super(); + storageKeysSyncRegistryService.registerStorageKey({ key: ignoredRecommendationsStorageKey, version: 1 }); + this._globalIgnoredRecommendations = this.getCachedIgnoredRecommendations(); + this._register(this.storageService.onDidChangeStorage(e => this.onDidStorageChange(e))); + + this.initIgnoredWorkspaceRecommendations(); + } + + private async initIgnoredWorkspaceRecommendations(): Promise { + this.ignoredWorkspaceRecommendations = await this.workpsaceExtensionsConfigService.getUnwantedRecommendations(); + this._onDidChangeIgnoredRecommendations.fire(); + this._register(this.workpsaceExtensionsConfigService.onDidChangeExtensionsConfigs(async () => { + this.ignoredWorkspaceRecommendations = await this.workpsaceExtensionsConfigService.getUnwantedRecommendations(); + this._onDidChangeIgnoredRecommendations.fire(); + })); + } + + toggleGlobalIgnoredRecommendation(extensionId: string, shouldIgnore: boolean): void { + extensionId = extensionId.toLowerCase(); + const ignored = this._globalIgnoredRecommendations.indexOf(extensionId) !== -1; + if (ignored === shouldIgnore) { + return; + } + + this._globalIgnoredRecommendations = shouldIgnore ? [...this._globalIgnoredRecommendations, extensionId] : this._globalIgnoredRecommendations.filter(id => id !== extensionId); + this.storeCachedIgnoredRecommendations(this._globalIgnoredRecommendations); + this._onDidChangeGlobalIgnoredRecommendation.fire({ extensionId, isRecommended: !shouldIgnore }); + this._onDidChangeIgnoredRecommendations.fire(); + } + + private getCachedIgnoredRecommendations(): string[] { + const ignoredRecommendations: string[] = JSON.parse(this.ignoredRecommendationsValue); + return ignoredRecommendations.map(e => e.toLowerCase()); + } + + private onDidStorageChange(e: IWorkspaceStorageChangeEvent): void { + if (e.key === ignoredRecommendationsStorageKey && e.scope === StorageScope.GLOBAL + && this.ignoredRecommendationsValue !== this.getStoredIgnoredRecommendationsValue() /* This checks if current window changed the value or not */) { + this._ignoredRecommendationsValue = undefined; + this._globalIgnoredRecommendations = this.getCachedIgnoredRecommendations(); + this._onDidChangeIgnoredRecommendations.fire(); + } + } + + private storeCachedIgnoredRecommendations(ignoredRecommendations: string[]): void { + this.ignoredRecommendationsValue = JSON.stringify(ignoredRecommendations); + } + + private _ignoredRecommendationsValue: string | undefined; + private get ignoredRecommendationsValue(): string { + if (!this._ignoredRecommendationsValue) { + this._ignoredRecommendationsValue = this.getStoredIgnoredRecommendationsValue(); + } + + return this._ignoredRecommendationsValue; + } + + private set ignoredRecommendationsValue(ignoredRecommendationsValue: string) { + if (this.ignoredRecommendationsValue !== ignoredRecommendationsValue) { + this._ignoredRecommendationsValue = ignoredRecommendationsValue; + this.setStoredIgnoredRecommendationsValue(ignoredRecommendationsValue); + } + } + + private getStoredIgnoredRecommendationsValue(): string { + return this.storageService.get(ignoredRecommendationsStorageKey, StorageScope.GLOBAL, '[]'); + } + + private setStoredIgnoredRecommendationsValue(value: string): void { + this.storageService.store(ignoredRecommendationsStorageKey, value, StorageScope.GLOBAL); + } + +} + +registerSingleton(IExtensionIgnoredRecommendationsService, ExtensionIgnoredRecommendationsService); diff --git a/src/vs/workbench/services/extensionRecommendations/common/extensionRecommendations.ts b/src/vs/workbench/services/extensionRecommendations/common/extensionRecommendations.ts index 921a9ab8b2f..21fb2dcf206 100644 --- a/src/vs/workbench/services/extensionRecommendations/common/extensionRecommendations.ts +++ b/src/vs/workbench/services/extensionRecommendations/common/extensionRecommendations.ts @@ -3,20 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from 'vs/base/common/event'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IStringDictionary } from 'vs/base/common/collections'; +import { Event } from 'vs/base/common/event'; export interface IExtensionsConfigContent { recommendations: string[]; unwantedRecommendations: string[]; } -export type RecommendationChangeNotification = { - extensionId: string, - isRecommended: boolean -}; - export type DynamicRecommendation = 'dynamic'; export type ConfigRecommendation = 'config'; export type ExecutableRecommendation = 'executable'; @@ -44,7 +39,9 @@ export const IExtensionRecommendationsService = createDecorator; getAllRecommendationsWithReason(): IStringDictionary; + getImportantRecommendations(): Promise; getOtherRecommendations(): Promise; getFileBasedRecommendations(): string[]; @@ -52,8 +49,24 @@ export interface IExtensionRecommendationsService { getConfigBasedRecommendations(): Promise<{ important: string[], others: string[] }>; getWorkspaceRecommendations(): Promise; getKeymapRecommendations(): string[]; - - toggleIgnoredRecommendation(extensionId: string, shouldIgnore: boolean): void; - getIgnoredRecommendations(): ReadonlyArray; - onRecommendationChange: Event; } + +export type IgnoredRecommendationChangeNotification = { + extensionId: string, + isRecommended: boolean +}; + +export const IExtensionIgnoredRecommendationsService = createDecorator('IExtensionIgnoredRecommendationsService'); + +export interface IExtensionIgnoredRecommendationsService { + readonly _serviceBrand: undefined; + + onDidChangeIgnoredRecommendations: Event; + readonly ignoredRecommendations: string[]; + + onDidChangeGlobalIgnoredRecommendation: Event; + readonly globalIgnoredRecommendations: string[]; + toggleGlobalIgnoredRecommendation(extensionId: string, ignore: boolean): void; +} + + diff --git a/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts b/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts index 8ef8e6c3ebf..dfcdaf50147 100644 --- a/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts +++ b/src/vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { coalesce, distinct, flatten } from 'vs/base/common/arrays'; +import { Emitter, Event } from 'vs/base/common/event'; import { parse } from 'vs/base/common/json'; +import { Disposable } from 'vs/base/common/lifecycle'; import { IFileService } from 'vs/platform/files/common/files'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -22,19 +24,26 @@ export const IWorkpsaceExtensionsConfigService = createDecorator; getExtensionsConfigs(): Promise; getUnwantedRecommendations(): Promise; } -export class WorkspaceExtensionsConfigService implements IWorkpsaceExtensionsConfigService { +export class WorkspaceExtensionsConfigService extends Disposable implements IWorkpsaceExtensionsConfigService { declare readonly _serviceBrand: undefined; + private readonly _onDidChangeExtensionsConfigs = this._register(new Emitter()); + readonly onDidChangeExtensionsConfigs = this._onDidChangeExtensionsConfigs.event; + constructor( @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IFileService private readonly fileService: IFileService, - ) { } + ) { + super(); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(e => this._onDidChangeExtensionsConfigs.fire())); + } async getExtensionsConfigs(): Promise { const workspace = this.workspaceContextService.getWorkspace(); @@ -47,7 +56,7 @@ export class WorkspaceExtensionsConfigService implements IWorkpsaceExtensionsCon async getUnwantedRecommendations(): Promise { const configs = await this.getExtensionsConfigs(); - return distinct(flatten(configs.map(c => c.unwantedRecommendations))); + return distinct(flatten(configs.map(c => c.unwantedRecommendations.map(c => c.toLowerCase())))); } private async resolveWorkspaceExtensionConfig(workspace: IWorkspace): Promise { diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index be98cac2d73..5adea5d1a72 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -75,6 +75,7 @@ import 'vs/workbench/services/label/common/labelService'; import 'vs/workbench/services/extensionManagement/common/webExtensionsScannerService'; import 'vs/workbench/services/extensionManagement/common/extensionEnablementService'; import 'vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService'; +import 'vs/workbench/services/extensionRecommendations/common/extensionIgnoredRecommendationsService'; import 'vs/workbench/services/extensionRecommendations/common/workspaceExtensionsConfig'; import 'vs/workbench/services/notification/common/notificationService'; import 'vs/workbench/services/userDataSync/common/userDataSyncUtil';