Path: blob/main/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { distinct } from '../../../../base/common/arrays.js';6import { CancelablePromise, createCancelablePromise, Promises, raceCancellablePromises, raceCancellation, timeout } from '../../../../base/common/async.js';7import { CancellationToken } from '../../../../base/common/cancellation.js';8import { isCancellationError } from '../../../../base/common/errors.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';11import { isString } from '../../../../base/common/types.js';12import { URI } from '../../../../base/common/uri.js';13import { localize } from '../../../../nls.js';14import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';15import { IGalleryExtension } from '../../../../platform/extensionManagement/common/extensionManagement.js';16import { areSameExtensions } from '../../../../platform/extensionManagement/common/extensionManagementUtil.js';17import { IExtensionRecommendationNotificationService, IExtensionRecommendations, RecommendationsNotificationResult, RecommendationSource, RecommendationSourceToString } from '../../../../platform/extensionRecommendations/common/extensionRecommendations.js';18import { INotificationHandle, INotificationService, IPromptChoice, IPromptChoiceWithMenu, NotificationPriority, Severity } from '../../../../platform/notification/common/notification.js';19import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';20import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';21import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';22import { IUserDataSyncEnablementService, SyncResource } from '../../../../platform/userDataSync/common/userDataSync.js';23import { IExtension, IExtensionsWorkbenchService } from '../common/extensions.js';24import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';25import { EnablementState, IWorkbenchExtensionManagementService, IWorkbenchExtensionEnablementService } from '../../../services/extensionManagement/common/extensionManagement.js';26import { IExtensionIgnoredRecommendationsService } from '../../../services/extensionRecommendations/common/extensionRecommendations.js';2728type ExtensionRecommendationsNotificationClassification = {29owner: 'sandy081';30comment: 'Response information when an extension is recommended';31userReaction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'User reaction after showing the recommendation prompt. Eg., install, cancel, show, neverShowAgain' };32extensionId?: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'Id of the extension that is recommended' };33source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source from which this recommendation is coming from. Eg., file, exe.,' };34};3536type ExtensionWorkspaceRecommendationsNotificationClassification = {37owner: 'sandy081';38comment: 'Response information when a recommendation from workspace is recommended';39userReaction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'User reaction after showing the recommendation prompt. Eg., install, cancel, show, neverShowAgain' };40};4142const ignoreImportantExtensionRecommendationStorageKey = 'extensionsAssistant/importantRecommendationsIgnore';43const donotShowWorkspaceRecommendationsStorageKey = 'extensionsAssistant/workspaceRecommendationsIgnore';4445type RecommendationsNotificationActions = {46onDidInstallRecommendedExtensions(extensions: IExtension[]): void;47onDidShowRecommendedExtensions(extensions: IExtension[]): void;48onDidCancelRecommendedExtensions(extensions: IExtension[]): void;49onDidNeverShowRecommendedExtensionsAgain(extensions: IExtension[]): void;50};5152type ExtensionRecommendations = Omit<IExtensionRecommendations, 'extensions'> & { extensions: Array<string | URI> };5354class RecommendationsNotification extends Disposable {5556private _onDidClose = this._register(new Emitter<void>());57readonly onDidClose = this._onDidClose.event;5859private _onDidChangeVisibility = this._register(new Emitter<boolean>());60readonly onDidChangeVisibility = this._onDidChangeVisibility.event;6162private notificationHandle: INotificationHandle | undefined;63private cancelled: boolean = false;6465constructor(66private readonly severity: Severity,67private readonly message: string,68private readonly choices: IPromptChoice[],69private readonly notificationService: INotificationService70) {71super();72}7374show(): void {75if (!this.notificationHandle) {76this.updateNotificationHandle(this.notificationService.prompt(this.severity, this.message, this.choices, { sticky: true, priority: NotificationPriority.OPTIONAL, onCancel: () => this.cancelled = true }));77}78}7980hide(): void {81if (this.notificationHandle) {82this.onDidCloseDisposable.clear();83this.notificationHandle.close();84this.cancelled = false;85this.updateNotificationHandle(this.notificationService.prompt(this.severity, this.message, this.choices, { priority: NotificationPriority.SILENT, onCancel: () => this.cancelled = true }));86}87}8889isCancelled(): boolean {90return this.cancelled;91}9293private readonly onDidCloseDisposable = this._register(new MutableDisposable());94private readonly onDidChangeVisibilityDisposable = this._register(new MutableDisposable());95private updateNotificationHandle(notificationHandle: INotificationHandle) {96this.onDidCloseDisposable.clear();97this.onDidChangeVisibilityDisposable.clear();98this.notificationHandle = notificationHandle;99100this.onDidCloseDisposable.value = this.notificationHandle.onDidClose(() => {101this.onDidCloseDisposable.dispose();102this.onDidChangeVisibilityDisposable.dispose();103104this._onDidClose.fire();105106this._onDidClose.dispose();107this._onDidChangeVisibility.dispose();108});109this.onDidChangeVisibilityDisposable.value = this.notificationHandle.onDidChangeVisibility((e) => this._onDidChangeVisibility.fire(e));110}111}112113type PendingRecommendationsNotification = { recommendationsNotification: RecommendationsNotification; source: RecommendationSource; token: CancellationToken };114type VisibleRecommendationsNotification = { recommendationsNotification: RecommendationsNotification; source: RecommendationSource; from: number };115116export class ExtensionRecommendationNotificationService extends Disposable implements IExtensionRecommendationNotificationService {117118declare readonly _serviceBrand: undefined;119120// Ignored Important Recommendations121get ignoredRecommendations(): string[] {122return distinct([...(<string[]>JSON.parse(this.storageService.get(ignoreImportantExtensionRecommendationStorageKey, StorageScope.PROFILE, '[]')))].map(i => i.toLowerCase()));123}124125private recommendedExtensions: string[] = [];126private recommendationSources: RecommendationSource[] = [];127128private hideVisibleNotificationPromise: CancelablePromise<void> | undefined;129private visibleNotification: VisibleRecommendationsNotification | undefined;130private pendingNotificaitons: PendingRecommendationsNotification[] = [];131132constructor(133@IConfigurationService private readonly configurationService: IConfigurationService,134@IStorageService private readonly storageService: IStorageService,135@INotificationService private readonly notificationService: INotificationService,136@ITelemetryService private readonly telemetryService: ITelemetryService,137@IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService,138@IWorkbenchExtensionManagementService private readonly extensionManagementService: IWorkbenchExtensionManagementService,139@IWorkbenchExtensionEnablementService private readonly extensionEnablementService: IWorkbenchExtensionEnablementService,140@IExtensionIgnoredRecommendationsService private readonly extensionIgnoredRecommendationsService: IExtensionIgnoredRecommendationsService,141@IUserDataSyncEnablementService private readonly userDataSyncEnablementService: IUserDataSyncEnablementService,142@IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService,143@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,144) {145super();146}147148hasToIgnoreRecommendationNotifications(): boolean {149const config = this.configurationService.getValue<{ ignoreRecommendations: boolean; showRecommendationsOnlyOnDemand?: boolean }>('extensions');150return config.ignoreRecommendations || !!config.showRecommendationsOnlyOnDemand;151}152153async promptImportantExtensionsInstallNotification(extensionRecommendations: IExtensionRecommendations): Promise<RecommendationsNotificationResult> {154const ignoredRecommendations = [...this.extensionIgnoredRecommendationsService.ignoredRecommendations, ...this.ignoredRecommendations];155const extensions = extensionRecommendations.extensions.filter(id => !ignoredRecommendations.includes(id));156if (!extensions.length) {157return RecommendationsNotificationResult.Ignored;158}159160return this.promptRecommendationsNotification({ ...extensionRecommendations, extensions }, {161onDidInstallRecommendedExtensions: (extensions: IExtension[]) => extensions.forEach(extension => this.telemetryService.publicLog2<{ userReaction: string; extensionId: string; source: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'install', extensionId: extension.identifier.id, source: RecommendationSourceToString(extensionRecommendations.source) })),162onDidShowRecommendedExtensions: (extensions: IExtension[]) => extensions.forEach(extension => this.telemetryService.publicLog2<{ userReaction: string; extensionId: string; source: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'show', extensionId: extension.identifier.id, source: RecommendationSourceToString(extensionRecommendations.source) })),163onDidCancelRecommendedExtensions: (extensions: IExtension[]) => extensions.forEach(extension => this.telemetryService.publicLog2<{ userReaction: string; extensionId: string; source: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'cancelled', extensionId: extension.identifier.id, source: RecommendationSourceToString(extensionRecommendations.source) })),164onDidNeverShowRecommendedExtensionsAgain: (extensions: IExtension[]) => {165for (const extension of extensions) {166this.addToImportantRecommendationsIgnore(extension.identifier.id);167this.telemetryService.publicLog2<{ userReaction: string; extensionId: string; source: string }, ExtensionRecommendationsNotificationClassification>('extensionRecommendations:popup', { userReaction: 'neverShowAgain', extensionId: extension.identifier.id, source: RecommendationSourceToString(extensionRecommendations.source) });168}169this.notificationService.prompt(170Severity.Info,171localize('ignoreExtensionRecommendations', "Do you want to ignore all extension recommendations?"),172[{173label: localize('ignoreAll', "Yes, Ignore All"),174run: () => this.setIgnoreRecommendationsConfig(true)175}, {176label: localize('no', "No"),177run: () => this.setIgnoreRecommendationsConfig(false)178}]179);180},181});182}183184async promptWorkspaceRecommendations(recommendations: Array<string | URI>): Promise<void> {185if (this.storageService.getBoolean(donotShowWorkspaceRecommendationsStorageKey, StorageScope.WORKSPACE, false)) {186return;187}188189let installed = await this.extensionManagementService.getInstalled();190installed = installed.filter(l => this.extensionEnablementService.getEnablementState(l) !== EnablementState.DisabledByExtensionKind); // Filter extensions disabled by kind191recommendations = recommendations.filter(recommendation => installed.every(local =>192isString(recommendation) ? !areSameExtensions({ id: recommendation }, local.identifier) : !this.uriIdentityService.extUri.isEqual(recommendation, local.location)193));194if (!recommendations.length) {195return;196}197198await this.promptRecommendationsNotification({ extensions: recommendations, source: RecommendationSource.WORKSPACE, name: localize({ key: 'this repository', comment: ['this repository means the current repository that is opened'] }, "this repository") }, {199onDidInstallRecommendedExtensions: () => this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'install' }),200onDidShowRecommendedExtensions: () => this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'show' }),201onDidCancelRecommendedExtensions: () => this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'cancelled' }),202onDidNeverShowRecommendedExtensionsAgain: () => {203this.telemetryService.publicLog2<{ userReaction: string }, ExtensionWorkspaceRecommendationsNotificationClassification>('extensionWorkspaceRecommendations:popup', { userReaction: 'neverShowAgain' });204this.storageService.store(donotShowWorkspaceRecommendationsStorageKey, true, StorageScope.WORKSPACE, StorageTarget.MACHINE);205},206});207208}209210private async promptRecommendationsNotification({ extensions: extensionIds, source, name, searchValue }: ExtensionRecommendations, recommendationsNotificationActions: RecommendationsNotificationActions): Promise<RecommendationsNotificationResult> {211212if (this.hasToIgnoreRecommendationNotifications()) {213return RecommendationsNotificationResult.Ignored;214}215216// Do not show exe based recommendations in remote window217if (source === RecommendationSource.EXE && this.workbenchEnvironmentService.remoteAuthority) {218return RecommendationsNotificationResult.IncompatibleWindow;219}220221// Ignore exe recommendation if the window222// => has shown an exe based recommendation already223// => or has shown any two recommendations already224if (source === RecommendationSource.EXE && (this.recommendationSources.includes(RecommendationSource.EXE) || this.recommendationSources.length >= 2)) {225return RecommendationsNotificationResult.TooMany;226}227228this.recommendationSources.push(source);229230// Ignore exe recommendation if recommendations are already shown231if (source === RecommendationSource.EXE && extensionIds.every(id => isString(id) && this.recommendedExtensions.includes(id))) {232return RecommendationsNotificationResult.Ignored;233}234235const extensions = await this.getInstallableExtensions(extensionIds);236if (!extensions.length) {237return RecommendationsNotificationResult.Ignored;238}239240this.recommendedExtensions = distinct([...this.recommendedExtensions, ...extensionIds.filter(isString)]);241242let extensionsMessage = '';243if (extensions.length === 1) {244extensionsMessage = localize('extensionFromPublisher', "'{0}' extension from {1}", extensions[0].displayName, extensions[0].publisherDisplayName);245} else {246const publishers = [...extensions.reduce((result, extension) => result.add(extension.publisherDisplayName), new Set<string>())];247if (publishers.length > 2) {248extensionsMessage = localize('extensionsFromMultiplePublishers', "extensions from {0}, {1} and others", publishers[0], publishers[1]);249} else if (publishers.length === 2) {250extensionsMessage = localize('extensionsFromPublishers', "extensions from {0} and {1}", publishers[0], publishers[1]);251} else {252extensionsMessage = localize('extensionsFromPublisher', "extensions from {0}", publishers[0]);253}254}255256let message = localize('recommended', "Do you want to install the recommended {0} for {1}?", extensionsMessage, name);257if (source === RecommendationSource.EXE) {258message = localize({ key: 'exeRecommended', comment: ['Placeholder string is the name of the software that is installed.'] }, "You have {0} installed on your system. Do you want to install the recommended {1} for it?", name, extensionsMessage);259}260if (!searchValue) {261searchValue = source === RecommendationSource.WORKSPACE ? '@recommended' : extensions.map(extensionId => `@id:${extensionId.identifier.id}`).join(' ');262}263264const donotShowAgainLabel = source === RecommendationSource.WORKSPACE ? localize('donotShowAgain', "Don't Show Again for this Repository")265: extensions.length > 1 ? localize('donotShowAgainExtension', "Don't Show Again for these Extensions") : localize('donotShowAgainExtensionSingle', "Don't Show Again for this Extension");266267return raceCancellablePromises([268this._registerP(this.showRecommendationsNotification(extensions, message, searchValue, donotShowAgainLabel, source, recommendationsNotificationActions)),269this._registerP(this.waitUntilRecommendationsAreInstalled(extensions))270]);271272}273274private showRecommendationsNotification(extensions: IExtension[], message: string, searchValue: string, donotShowAgainLabel: string, source: RecommendationSource,275{ onDidInstallRecommendedExtensions, onDidShowRecommendedExtensions, onDidCancelRecommendedExtensions, onDidNeverShowRecommendedExtensionsAgain }: RecommendationsNotificationActions): CancelablePromise<RecommendationsNotificationResult> {276return createCancelablePromise<RecommendationsNotificationResult>(async token => {277let accepted = false;278const choices: (IPromptChoice | IPromptChoiceWithMenu)[] = [];279const installExtensions = async (isMachineScoped: boolean) => {280this.extensionsWorkbenchService.openSearch(searchValue);281onDidInstallRecommendedExtensions(extensions);282const galleryExtensions: IGalleryExtension[] = [], resourceExtensions: IExtension[] = [];283for (const extension of extensions) {284if (extension.gallery) {285galleryExtensions.push(extension.gallery);286} else if (extension.resourceExtension) {287resourceExtensions.push(extension);288}289}290await Promises.settled<any>([291Promises.settled(extensions.map(extension => this.extensionsWorkbenchService.open(extension, { pinned: true }))),292galleryExtensions.length ? this.extensionManagementService.installGalleryExtensions(galleryExtensions.map(e => ({ extension: e, options: { isMachineScoped } }))) : Promise.resolve(),293resourceExtensions.length ? Promise.allSettled(resourceExtensions.map(r => this.extensionsWorkbenchService.install(r))) : Promise.resolve()294]);295};296choices.push({297label: localize('install', "Install"),298run: () => installExtensions(false),299menu: this.userDataSyncEnablementService.isEnabled() && this.userDataSyncEnablementService.isResourceEnabled(SyncResource.Extensions) ? [{300label: localize('install and do no sync', "Install (Do not sync)"),301run: () => installExtensions(true)302}] : undefined,303});304choices.push(...[{305label: localize('show recommendations', "Show Recommendations"),306run: async () => {307onDidShowRecommendedExtensions(extensions);308for (const extension of extensions) {309this.extensionsWorkbenchService.open(extension, { pinned: true });310}311this.extensionsWorkbenchService.openSearch(searchValue);312}313}, {314label: donotShowAgainLabel,315isSecondary: true,316run: () => {317onDidNeverShowRecommendedExtensionsAgain(extensions);318}319}]);320try {321accepted = await this.doShowRecommendationsNotification(Severity.Info, message, choices, source, token);322} catch (error) {323if (!isCancellationError(error)) {324throw error;325}326}327328if (accepted) {329return RecommendationsNotificationResult.Accepted;330} else {331onDidCancelRecommendedExtensions(extensions);332return RecommendationsNotificationResult.Cancelled;333}334335});336}337338private waitUntilRecommendationsAreInstalled(extensions: IExtension[]): CancelablePromise<RecommendationsNotificationResult.Accepted> {339const installedExtensions: string[] = [];340const disposables = new DisposableStore();341return createCancelablePromise(async token => {342disposables.add(token.onCancellationRequested(e => disposables.dispose()));343return new Promise<RecommendationsNotificationResult.Accepted>((c, e) => {344disposables.add(this.extensionManagementService.onInstallExtension(e => {345installedExtensions.push(e.identifier.id.toLowerCase());346if (extensions.every(e => installedExtensions.includes(e.identifier.id.toLowerCase()))) {347c(RecommendationsNotificationResult.Accepted);348}349}));350});351});352}353354/**355* Show recommendations in Queue356* At any time only one recommendation is shown357* If a new recommendation comes in358* => If no recommendation is visible, show it immediately359* => Otherwise, add to the pending queue360* => If it is not exe based and has higher or same priority as current, hide the current notification after showing it for 3s.361* => Otherwise wait until the current notification is hidden.362*/363private async doShowRecommendationsNotification(severity: Severity, message: string, choices: IPromptChoice[], source: RecommendationSource, token: CancellationToken): Promise<boolean> {364const disposables = new DisposableStore();365try {366const recommendationsNotification = disposables.add(new RecommendationsNotification(severity, message, choices, this.notificationService));367disposables.add(Event.once(Event.filter(recommendationsNotification.onDidChangeVisibility, e => !e))(() => this.showNextNotification()));368if (this.visibleNotification) {369const index = this.pendingNotificaitons.length;370disposables.add(token.onCancellationRequested(() => this.pendingNotificaitons.splice(index, 1)));371this.pendingNotificaitons.push({ recommendationsNotification, source, token });372if (source !== RecommendationSource.EXE && source <= this.visibleNotification.source) {373this.hideVisibleNotification(3000);374}375} else {376this.visibleNotification = { recommendationsNotification, source, from: Date.now() };377recommendationsNotification.show();378}379await raceCancellation(new Promise(c => disposables.add(Event.once(recommendationsNotification.onDidClose)(c))), token);380return !recommendationsNotification.isCancelled();381} finally {382disposables.dispose();383}384}385386private showNextNotification(): void {387const index = this.getNextPendingNotificationIndex();388const [nextNotificaiton] = index > -1 ? this.pendingNotificaitons.splice(index, 1) : [];389390// Show the next notification after a delay of 500ms (after the current notification is dismissed)391timeout(nextNotificaiton ? 500 : 0)392.then(() => {393this.unsetVisibileNotification();394if (nextNotificaiton) {395this.visibleNotification = { recommendationsNotification: nextNotificaiton.recommendationsNotification, source: nextNotificaiton.source, from: Date.now() };396nextNotificaiton.recommendationsNotification.show();397}398});399}400401/**402* Return the recent high priroity pending notification403*/404private getNextPendingNotificationIndex(): number {405let index = this.pendingNotificaitons.length - 1;406if (this.pendingNotificaitons.length) {407for (let i = 0; i < this.pendingNotificaitons.length; i++) {408if (this.pendingNotificaitons[i].source <= this.pendingNotificaitons[index].source) {409index = i;410}411}412}413return index;414}415416private hideVisibleNotification(timeInMillis: number): void {417if (this.visibleNotification && !this.hideVisibleNotificationPromise) {418const visibleNotification = this.visibleNotification;419this.hideVisibleNotificationPromise = timeout(Math.max(timeInMillis - (Date.now() - visibleNotification.from), 0));420this.hideVisibleNotificationPromise.then(() => visibleNotification.recommendationsNotification.hide());421}422}423424private unsetVisibileNotification(): void {425this.hideVisibleNotificationPromise?.cancel();426this.hideVisibleNotificationPromise = undefined;427this.visibleNotification = undefined;428}429430private async getInstallableExtensions(recommendations: Array<string | URI>): Promise<IExtension[]> {431const result: IExtension[] = [];432if (recommendations.length) {433const galleryExtensions: string[] = [];434const resourceExtensions: URI[] = [];435for (const recommendation of recommendations) {436if (typeof recommendation === 'string') {437galleryExtensions.push(recommendation);438} else {439resourceExtensions.push(recommendation);440}441}442if (galleryExtensions.length) {443const extensions = await this.extensionsWorkbenchService.getExtensions(galleryExtensions.map(id => ({ id })), { source: 'install-recommendations' }, CancellationToken.None);444for (const extension of extensions) {445if (extension.gallery && await this.extensionManagementService.canInstall(extension.gallery) === true) {446result.push(extension);447}448}449}450if (resourceExtensions.length) {451const extensions = await this.extensionsWorkbenchService.getResourceExtensions(resourceExtensions, true);452for (const extension of extensions) {453if (await this.extensionsWorkbenchService.canInstall(extension) === true) {454result.push(extension);455}456}457}458}459return result;460}461462private addToImportantRecommendationsIgnore(id: string) {463const importantRecommendationsIgnoreList = [...this.ignoredRecommendations];464if (!importantRecommendationsIgnoreList.includes(id.toLowerCase())) {465importantRecommendationsIgnoreList.push(id.toLowerCase());466this.storageService.store(ignoreImportantExtensionRecommendationStorageKey, JSON.stringify(importantRecommendationsIgnoreList), StorageScope.PROFILE, StorageTarget.USER);467}468}469470private setIgnoreRecommendationsConfig(configVal: boolean) {471this.configurationService.updateValue('extensions.ignoreRecommendations', configVal);472}473474private _registerP<T>(o: CancelablePromise<T>): CancelablePromise<T> {475this._register(toDisposable(() => o.cancel()));476return o;477}478}479480481