diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 0a35c0f0cd1448dbbb13f1698d3a07084bd7a1b2..12e6500741873a15584048a5833d1a6e9051dad9 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -376,6 +376,21 @@ export interface IExtensionEnablementService { setEnablement(extension: ILocalExtension, state: EnablementState): TPromise; } +export interface IIgnoredRecommendations { + global: string[]; + workspace: string[]; +} + +export interface IExtensionsConfigContent { + recommendations: string[]; + unwantedRecommendations: string[]; +} + +export type RecommendationChangeNotification = { + extensionId: string, + isRecommended: boolean +}; + export const IExtensionTipsService = createDecorator('extensionTipsService'); export interface IExtensionTipsService { @@ -387,6 +402,9 @@ export interface IExtensionTipsService { getKeymapRecommendations(): string[]; getKeywordsForExtension(extension: string): string[]; getRecommendationsForExtension(extension: string): string[]; + getAllIgnoredRecommendations(): IIgnoredRecommendations; + ignoreExtensionRecommendation(extensionId: string): void; + onRecommendationChange: Event; } export enum ExtensionRecommendationReason { diff --git a/src/vs/platform/node/product.ts b/src/vs/platform/node/product.ts index 685ccc5d3740b6898c8ff679d48814ccda8c5147..c60c4d01ec620b430ccea616e30b7828a6a155ec 100644 --- a/src/vs/platform/node/product.ts +++ b/src/vs/platform/node/product.ts @@ -30,7 +30,7 @@ export interface IProductConfiguration { }; extensionTips: { [id: string]: string; }; extensionImportantTips: { [id: string]: { name: string; pattern: string; }; }; - exeBasedExtensionTips: { [id: string]: any; }; + exeBasedExtensionTips: { [id: string]: { friendlyName: string, windowsPath?: string, recommendations: string[] }; }; extensionKeywords: { [extension: string]: string[]; }; extensionAllowedBadgeProviders: string[]; extensionAllowedProposedApi: string[]; diff --git a/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts b/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts index fc81cb063d1b9ed7c70098624719e3442c23499c..a6d988dfb7ccb5716ba19422ce69e41d4087dbd3 100644 --- a/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts +++ b/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts @@ -13,10 +13,20 @@ export const ExtensionsConfigurationSchema: IJSONSchema = { allowComments: true, type: 'object', title: localize('app.extensions.json.title', "Extensions"), + additionalProperties: false, properties: { recommendations: { type: 'array', - description: localize('app.extensions.json.recommendations', "List of extensions recommendations. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), + description: localize('app.extensions.json.recommendations', "List of extensions which should be recommended for users of this workspace. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), + items: { + type: 'string', + pattern: EXTENSION_IDENTIFIER_PATTERN, + errorMessage: localize('app.extension.identifier.errorMessage', "Expected format '${publisher}.${name}'. Example: 'vscode.csharp'.") + }, + }, + unwantedRecommendations: { + type: 'array', + description: localize('app.extensions.json.unwantedRecommendations', "List of extensions that will be skipped from the recommendations that VS Code makes for the users of this workspace. These are extensions that you may consider to be irrelevant, redundant, or otherwise unwanted. The identifier of an extension is always '${publisher}.${name}'. For example: 'vscode.csharp'."), items: { type: 'string', pattern: EXTENSION_IDENTIFIER_PATTERN, @@ -31,6 +41,12 @@ export const ExtensionsConfigurationInitialContent: string = [ '\t// See http://go.microsoft.com/fwlink/?LinkId=827846', '\t// for the documentation about the extensions.json format', '\t"recommendations": [', + '\t\t// List of extensions which should be recommended for users of this workspace.', + '\t\t// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp', + '\t\t', + '\t],', + '\t"unwantedRecommendations": [', + '\t\t// List of extensions that will be skipped from the recommendations that VS Code makes for the users of this workspace. These are extensions that you may consider to be irrelevant, redundant, or otherwise unwanted.', '\t\t// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp', '\t\t', '\t]', diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionEditor.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionEditor.ts index e42c43d374906f3e85f52c00c24d135e11f5aeb9..3440e74a6ddcfb738e1ec38ef250f713c84baae7 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionEditor.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionEditor.ts @@ -31,7 +31,7 @@ import { Renderer, DataSource, Controller } from 'vs/workbench/parts/extensions/ import { RatingsWidget, InstallCountWidget } from 'vs/workbench/parts/extensions/browser/extensionsWidgets'; import { EditorOptions } from 'vs/workbench/common/editor'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; -import { CombinedInstallAction, UpdateAction, EnableAction, DisableAction, ReloadAction, MaliciousStatusLabelAction, DisabledStatusLabelAction, MultiServerInstallAction, MultiServerUpdateAction } from 'vs/workbench/parts/extensions/electron-browser/extensionsActions'; +import { CombinedInstallAction, UpdateAction, EnableAction, DisableAction, ReloadAction, MaliciousStatusLabelAction, DisabledStatusLabelAction, MultiServerInstallAction, MultiServerUpdateAction, IgnoreExtensionRecommendationAction } from 'vs/workbench/parts/extensions/electron-browser/extensionsActions'; import { WebviewElement } from 'vs/workbench/parts/webview/electron-browser/webviewElement'; import { KeybindingIO } from 'vs/workbench/services/keybinding/common/keybindingIO'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; @@ -164,6 +164,8 @@ export class ExtensionEditor extends BaseEditor { private navbar: NavBar; private content: HTMLElement; private recommendation: HTMLElement; + private recommendationText: any; + private ignoreActionbar: ActionBar; private header: HTMLElement; private extensionReadme: Cache; @@ -256,15 +258,24 @@ export class ExtensionEditor extends BaseEditor { return null; } }); - this.disposables.push(this.extensionActionBar); this.recommendation = append(details, $('.recommendation')); + this.recommendationText = append(this.recommendation, $('.recommendation-text')); + this.ignoreActionbar = new ActionBar(this.recommendation, { animated: false }); + + this.disposables.push(this.extensionActionBar); + this.disposables.push(this.ignoreActionbar); chain(this.extensionActionBar.onDidRun) .map(({ error }) => error) .filter(error => !!error) .on(this.onError, this, this.disposables); + chain(this.ignoreActionbar.onDidRun) + .map(({ error }) => error) + .filter(error => !!error) + .on(this.onError, this, this.disposables); + const body = append(root, $('.body')); this.navbar = new NavBar(body); @@ -295,24 +306,25 @@ export class ExtensionEditor extends BaseEditor { this.publisher.textContent = extension.publisherDisplayName; this.description.textContent = extension.description; + removeClass(this.header, 'recommendation-ignored'); const extRecommendations = this.extensionTipsService.getAllRecommendationsWithReason(); let recommendationsData = {}; if (extRecommendations[extension.id.toLowerCase()]) { addClass(this.header, 'recommended'); - this.recommendation.textContent = extRecommendations[extension.id.toLowerCase()].reasonText; + this.recommendationText.textContent = extRecommendations[extension.id.toLowerCase()].reasonText; recommendationsData = { recommendationReason: extRecommendations[extension.id.toLowerCase()].reasonId }; } else { removeClass(this.header, 'recommended'); - this.recommendation.textContent = ''; + this.recommendationText.textContent = ''; } /* __GDPR__ - "extensionGallery:openExtension" : { - "recommendationReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, - "${include}": [ - "${GalleryExtensionTelemetryData}" - ] - } + "extensionGallery:openExtension" : { + "recommendationReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "${include}": [ + "${GalleryExtensionTelemetryData}" + ] + } */ this.telemetryService.publicLog('extensionGallery:openExtension', assign(extension.telemetryData, recommendationsData)); @@ -376,6 +388,21 @@ export class ExtensionEditor extends BaseEditor { this.extensionActionBar.push([disabledStatusAction, reloadAction, updateAction, enableAction, disableAction, installAction, maliciousStatusAction], { icon: true, label: true }); this.transientDisposables.push(enableAction, updateAction, reloadAction, disableAction, installAction, maliciousStatusAction, disabledStatusAction); + const ignoreAction = this.instantiationService.createInstance(IgnoreExtensionRecommendationAction); + ignoreAction.extension = extension; + + this.extensionTipsService.onRecommendationChange(change => { + if (change.extensionId.toLowerCase() === extension.id.toLowerCase() && change.isRecommended === false) { + addClass(this.header, 'recommendation-ignored'); + removeClass(this.header, 'recommended'); + this.recommendationText.textContent = localize('recommendationHasBeenIgnored', "You have chosen not to receive recommendations for this extension."); + } + }); + + this.ignoreActionbar.clear(); + this.ignoreActionbar.push([ignoreAction], { icon: true, label: true }); + this.transientDisposables.push(ignoreAction); + this.content.innerHTML = ''; // Clear content before setting navbar actions. this.navbar.clear(); diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts index 71cce68cb0bb5eb815a73b4ae83b0eed9152a887..9c3312d585eb3af06da1c4fa67429e4bdc5e60b7 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts @@ -10,7 +10,7 @@ import { forEach } from 'vs/base/common/collections'; import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; import { match } from 'vs/base/common/glob'; import * as json from 'vs/base/common/json'; -import { IExtensionManagementService, IExtensionGalleryService, IExtensionTipsService, ExtensionRecommendationReason, LocalExtensionType, EXTENSION_IDENTIFIER_PATTERN } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { IExtensionManagementService, IExtensionGalleryService, IExtensionTipsService, ExtensionRecommendationReason, LocalExtensionType, EXTENSION_IDENTIFIER_PATTERN, IIgnoredRecommendations, IExtensionsConfigContent, RecommendationChangeNotification } from 'vs/platform/extensionManagement/common/extensionManagement'; import { IModelService } from 'vs/editor/common/services/modelService'; import { ITextModel } from 'vs/editor/common/model'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; @@ -36,10 +36,7 @@ import { asJson } from 'vs/base/node/request'; import { isNumber } from 'vs/base/common/types'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { INotificationService } from 'vs/platform/notification/common/notification'; - -interface IExtensionsContent { - recommendations: string[]; -} +import { Emitter, Event } from 'vs/base/common/event'; const empty: { [key: string]: any; } = Object.create(null); const milliSecondsInADay = 1000 * 60 * 60 * 24; @@ -52,6 +49,15 @@ interface IDynamicWorkspaceRecommendations { recommendations: string[]; } +function caseInsensitiveGet(obj: { [key: string]: T }, key: string): T | undefined { + for (const _key in obj) { + if (obj.hasOwnProperty(_key) && _key.toLowerCase() === key.toLowerCase()) { + return obj[_key]; + } + } + return undefined; +} + export class ExtensionTipsService extends Disposable implements IExtensionTipsService { _serviceBrand: any; @@ -61,11 +67,17 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe private _availableRecommendations: { [pattern: string]: string[] } = Object.create(null); private _allWorkspaceRecommendedExtensions: string[] = []; private _dynamicWorkspaceRecommendations: string[] = []; + private _allIgnoredRecommendations: string[] = []; + private _globallyIgnoredRecommendations: string[] = []; + private _workspaceIgnoredRecommendations: string[] = []; private _extensionsRecommendationsUrl: string; private _disposables: IDisposable[] = []; - public promptWorkspaceRecommendationsPromise: TPromise; + public loadRecommendationsPromise: TPromise; private proactiveRecommendationsFetched: boolean = false; + private readonly _onRecommendationChange: Emitter = new Emitter(); + onRecommendationChange: Event = this._onRecommendationChange.event; + constructor( @IExtensionGalleryService private readonly _galleryService: IExtensionGalleryService, @IModelService private readonly _modelService: IModelService, @@ -92,9 +104,19 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe this._extensionsRecommendationsUrl = product.extensionsGallery.recommendationsUrl; } - this.getCachedDynamicWorkspaceRecommendations(); - this._suggestFileBasedRecommendations(); - this.promptWorkspaceRecommendationsPromise = this._suggestWorkspaceRecommendations(); + let globallyIgnored = JSON.parse(this.storageService.get('extensionsAssistant/ignored_recommendations', StorageScope.GLOBAL, '[]')); + this._globallyIgnoredRecommendations = globallyIgnored.map(id => id.toLowerCase()); + + this.loadRecommendationsPromise = this.getWorkspaceRecommendations() + .then(() => { + // these must be called after workspace configs have been refreshed. + this.getCachedDynamicWorkspaceRecommendations(); + this._suggestFileBasedRecommendations(); + return this._suggestWorkspaceRecommendations(); + }).then(() => { + this._modelService.onModelAdded(this._suggest, this, this._disposables); + this._modelService.getModels().forEach(model => this._suggest(model)); + }); if (!this.configurationService.getValue(ShowRecommendationsOnlyOnDemandKey)) { this.fetchProactiveRecommendations(true); @@ -164,41 +186,69 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe } getWorkspaceRecommendations(): TPromise { - if (!this.isEnabled()) { - return TPromise.as([]); - } - const workspace = this.contextService.getWorkspace(); - return TPromise.join([this.resolveWorkspaceRecommendations(workspace), ...workspace.folders.map(workspaceFolder => this.resolveWorkspaceFolderRecommendations(workspaceFolder))]) - .then(recommendations => { - this._allWorkspaceRecommendedExtensions = distinct(flatten(recommendations)); + if (!this.isEnabled) { return TPromise.as([]); } + + return this.fetchCombinedExtensionRecommendationConfig() + .then(content => { + this._workspaceIgnoredRecommendations = content.unwantedRecommendations; + this._allIgnoredRecommendations = distinct([...this._globallyIgnoredRecommendations, ...this._workspaceIgnoredRecommendations]); + this._allWorkspaceRecommendedExtensions = content.recommendations; + this.refilterAllRecommendations(); return this._allWorkspaceRecommendedExtensions; }); } - private resolveWorkspaceRecommendations(workspace: IWorkspace): TPromise { - if (workspace.configuration) { - return this.fileService.resolveContent(workspace.configuration) - .then(content => this.processWorkspaceRecommendations(json.parse(content.value, [])['extensions']), err => []); + private fetchCombinedExtensionRecommendationConfig(): TPromise { + const mergeExtensionRecommendationConfigs: (configs: IExtensionsConfigContent[]) => IExtensionsConfigContent = configs => ({ + recommendations: distinct(flatten(configs.map(config => config && config.recommendations || []))), + unwantedRecommendations: distinct(flatten(configs.map(config => config && config.unwantedRecommendations || []))) + }); + + const workspace = this.contextService.getWorkspace(); + return TPromise.join([this.resolveWorkspaceExtensionConfig(workspace), ...workspace.folders.map(workspaceFolder => this.resolveWorkspaceFolderExtensionConfig(workspaceFolder))]) + .then(contents => this.processConfigContent(mergeExtensionRecommendationConfigs(contents))); + } + + private resolveWorkspaceExtensionConfig(workspace: IWorkspace): TPromise { + if (!workspace.configuration) { + return TPromise.as(null); } - return TPromise.as([]); + + return this.fileService.resolveContent(workspace.configuration) + .then(content => (json.parse(content.value)['extensions']), err => null); } - private resolveWorkspaceFolderRecommendations(workspaceFolder: IWorkspaceFolder): TPromise { + private resolveWorkspaceFolderExtensionConfig(workspaceFolder: IWorkspaceFolder): TPromise { const extensionsJsonUri = workspaceFolder.toResource(paths.join('.vscode', 'extensions.json')); - return this.fileService.resolveFile(extensionsJsonUri).then(() => { - return this.fileService.resolveContent(extensionsJsonUri) - .then(content => this.processWorkspaceRecommendations(json.parse(content.value, [])), err => []); - }, err => []); + + return this.fileService.resolveFile(extensionsJsonUri) + .then(() => this.fileService.resolveContent(extensionsJsonUri)) + .then(content => json.parse(content.value), err => null); } - private processWorkspaceRecommendations(extensionsContent: IExtensionsContent): TPromise { + private processConfigContent(extensionsContent: IExtensionsConfigContent): TPromise { + if (!extensionsContent) { + return TPromise.as({ recommendations: [], unwantedRecommendations: [] }); + } + const regEx = new RegExp(EXTENSION_IDENTIFIER_PATTERN); - if (extensionsContent && extensionsContent.recommendations && extensionsContent.recommendations.length) { - let countBadRecommendations = 0; - let badRecommendationsString = ''; - let filteredRecommendations = extensionsContent.recommendations.filter((element, position) => { - if (extensionsContent.recommendations.indexOf(element) !== position) { + let countBadRecommendations = 0; + let badRecommendationsString = ''; + let errorsNotification = () => { + if (countBadRecommendations > 0 && this.notificationService) { + this.notificationService.warn( + 'The below ' + + countBadRecommendations + + ' extension(s) in workspace recommendations have issues:\n' + + badRecommendationsString + ); + } + }; + + let regexFilter = (ids: string[]) => { + return ids.filter((element, position) => { + if (ids.indexOf(element) !== position) { // This is a duplicate entry, it doesn't hurt anybody // but it shouldn't be sent in the gallery query return false; @@ -207,52 +257,76 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe badRecommendationsString += `${element} (bad format) Expected: .\n`; return false; } - return true; }); + }; + + let filteredWanted = regexFilter(extensionsContent.recommendations || []).map(x => x.toLowerCase()); + let filteredUnwanted = regexFilter(extensionsContent.unwantedRecommendations || []).map(x => x.toLowerCase()); + + if (!filteredWanted.length) { + errorsNotification(); + return TPromise.as({ recommendations: filteredWanted, unwantedRecommendations: filteredUnwanted }); + } + + return this._galleryService.query({ names: filteredWanted }).then(pager => { + let page = pager.firstPage; + let validRecommendations = page.map(extension => { + return extension.identifier.id.toLowerCase(); + }); - return this._galleryService.query({ names: filteredRecommendations }).then(pager => { - let page = pager.firstPage; - let validRecommendations = page.map(extension => { - return extension.identifier.id.toLowerCase(); + if (validRecommendations.length !== filteredWanted.length) { + filteredWanted.forEach(element => { + if (validRecommendations.indexOf(element.toLowerCase()) === -1) { + countBadRecommendations++; + badRecommendationsString += `${element} (not found in marketplace)\n`; + } }); + } - if (validRecommendations.length !== filteredRecommendations.length) { - filteredRecommendations.forEach(element => { - if (validRecommendations.indexOf(element.toLowerCase()) === -1) { - countBadRecommendations++; - badRecommendationsString += `${element} (not found in marketplace)\n`; - } - }); - } + errorsNotification(); + return { recommendations: validRecommendations, unwantedRecommendations: filteredUnwanted }; + }); + } - if (countBadRecommendations > 0 && this.notificationService) { - this.notificationService.warn( - 'The below ' + - countBadRecommendations + - ' extension(s) in workspace recommendations have issues:\n' + - badRecommendationsString - ); - } + private refilterAllRecommendations() { + this._allWorkspaceRecommendedExtensions = this._allWorkspaceRecommendedExtensions.filter((id) => this.isExtensionAllowedToBeRecommended(id)); + this._dynamicWorkspaceRecommendations = this._dynamicWorkspaceRecommendations.filter((id) => this.isExtensionAllowedToBeRecommended(id)); - return validRecommendations; - }); + this._allIgnoredRecommendations.forEach(x => { + delete this._fileBasedRecommendations[x]; + delete this._exeBasedRecommendations[x]; + }); + + if (this._availableRecommendations) { + for (const key in this._availableRecommendations) { + if (Object.prototype.hasOwnProperty.call(this._availableRecommendations, key)) { + this._availableRecommendations[key] = this._availableRecommendations[key].filter(id => this.isExtensionAllowedToBeRecommended(id)); + } + } } + } - return TPromise.as([]); + getAllIgnoredRecommendations(): IIgnoredRecommendations { + return { + workspace: this._workspaceIgnoredRecommendations, + global: this._globallyIgnoredRecommendations + }; + } + private isExtensionAllowedToBeRecommended(id: string): boolean { + return this._allIgnoredRecommendations.indexOf(id.toLowerCase()) === -1; } private onWorkspaceFoldersChanged(event: IWorkspaceFoldersChangeEvent): void { if (event.added.length) { - TPromise.join(event.added.map(workspaceFolder => this.resolveWorkspaceFolderRecommendations(workspaceFolder))) - .then(result => { - const newRecommendations = flatten(result); - // Suggest only if atleast one of the newly added recommendtations was not suggested before - if (newRecommendations.some(e => this._allWorkspaceRecommendedExtensions.indexOf(e) === -1)) { - this._suggestWorkspaceRecommendations(); - } - }); + const oldWorkspaceRecommended = this._allWorkspaceRecommendedExtensions; + this.getWorkspaceRecommendations().then(result => { + // Suggest only if at least one of the newly added recommendations was not suggested before + if (result.some(e => oldWorkspaceRecommended.indexOf(e) === -1)) { + this._suggestWorkspaceRecommendations(); + } + }); } this._dynamicWorkspaceRecommendations = []; } @@ -261,10 +335,10 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe const fileBased = Object.keys(this._fileBasedRecommendations) .sort((a, b) => { if (this._fileBasedRecommendations[a] === this._fileBasedRecommendations[b]) { - if (!product.extensionImportantTips || product.extensionImportantTips[a]) { + if (!product.extensionImportantTips || caseInsensitiveGet(product.extensionImportantTips, a)) { return -1; } - if (product.extensionImportantTips[b]) { + if (caseInsensitiveGet(product.extensionImportantTips, b)) { return 1; } } @@ -295,29 +369,30 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe this._availableRecommendations = Object.create(null); forEach(extensionTips, entry => { let { key: id, value: pattern } = entry; - let ids = this._availableRecommendations[pattern]; - if (!ids) { - this._availableRecommendations[pattern] = [id]; - } else { - ids.push(id); + if (this.isExtensionAllowedToBeRecommended(id)) { + let ids = this._availableRecommendations[pattern]; + if (!ids) { + this._availableRecommendations[pattern] = [id.toLowerCase()]; + } else { + ids.push(id.toLowerCase()); + } } }); forEach(product.extensionImportantTips, entry => { let { key: id, value } = entry; - const { pattern } = value; - let ids = this._availableRecommendations[pattern]; - if (!ids) { - this._availableRecommendations[pattern] = [id]; - } else { - ids.push(id); + if (this.isExtensionAllowedToBeRecommended(id)) { + const { pattern } = value; + let ids = this._availableRecommendations[pattern]; + if (!ids) { + this._availableRecommendations[pattern] = [id.toLowerCase()]; + } else { + ids.push(id.toLowerCase()); + } } }); - const allRecommendations: string[] = []; - forEach(this._availableRecommendations, ({ value: ids }) => { - allRecommendations.push(...ids); - }); + const allRecommendations: string[] = flatten((Object.keys(this._availableRecommendations).map(key => this._availableRecommendations[key]))); // retrieve ids of previous recommendations const storedRecommendationsJson = JSON.parse(this.storageService.get('extensionsAssistant/recommendations', StorageScope.GLOBAL, '[]')); @@ -325,7 +400,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe if (Array.isArray(storedRecommendationsJson)) { for (let id of storedRecommendationsJson) { if (allRecommendations.indexOf(id) > -1) { - this._fileBasedRecommendations[id] = Date.now(); + this._fileBasedRecommendations[id.toLowerCase()] = Date.now(); } } } else { @@ -334,14 +409,11 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe if (typeof entry.value === 'number') { const diff = (now - entry.value) / milliSecondsInADay; if (diff <= 7 && allRecommendations.indexOf(entry.key) > -1) { - this._fileBasedRecommendations[entry.key] = entry.value; + this._fileBasedRecommendations[entry.key.toLowerCase()] = entry.value; } } }); } - - this._modelService.onModelAdded(this._suggest, this, this._disposables); - this._modelService.getModels().forEach(model => this._suggest(model)); } private getMimeTypes(path: string): TPromise { @@ -370,12 +442,16 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe // the critical path - in case glob-match is slow setImmediate(() => { + let recommendationsToSuggest: string[] = []; const now = Date.now(); forEach(this._availableRecommendations, entry => { let { key: pattern, value: ids } = entry; if (match(pattern, uri.fsPath)) { for (let id of ids) { - this._fileBasedRecommendations[id] = now; + if (Object.keys(product.extensionImportantTips || []).map(x => x.toLowerCase()).indexOf(id.toLowerCase()) > -1) { + recommendationsToSuggest.push(id); + } + this._fileBasedRecommendations[id.toLowerCase()] = now; } } }); @@ -392,8 +468,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe } const importantRecommendationsIgnoreList = JSON.parse(this.storageService.get('extensionsAssistant/importantRecommendationsIgnore', StorageScope.GLOBAL, '[]')); - let recommendationsToSuggest = Object.keys(product.extensionImportantTips || []) - .filter(id => importantRecommendationsIgnoreList.indexOf(id) === -1 && match(product.extensionImportantTips[id]['pattern'], uri.fsPath)); + recommendationsToSuggest = recommendationsToSuggest.filter(id => importantRecommendationsIgnoreList.indexOf(id) === -1); const importantTipsPromise = recommendationsToSuggest.length === 0 ? TPromise.as(null) : this.extensionsService.getInstalled(LocalExtensionType.User).then(local => { recommendationsToSuggest = recommendationsToSuggest.filter(id => local.every(local => `${local.manifest.publisher}.${local.manifest.name}` !== id)); @@ -401,7 +476,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe return; } const id = recommendationsToSuggest[0]; - const name = product.extensionImportantTips[id]['name']; + const name = caseInsensitiveGet(product.extensionImportantTips, id)['name']; // Indicates we have a suggested extension via the whitelist hasSuggestion = true; @@ -555,86 +630,85 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe }); } - private _suggestWorkspaceRecommendations(): TPromise { + private _suggestWorkspaceRecommendations(): void { + const allRecommendations = this._allWorkspaceRecommendedExtensions; const storageKey = 'extensionsAssistant/workspaceRecommendationsIgnore'; const config = this.configurationService.getValue(ConfigurationKey); - return this.getWorkspaceRecommendations().then(allRecommendations => { - if (!allRecommendations.length || config.ignoreRecommendations || config.showRecommendationsOnlyOnDemand || this.storageService.getBoolean(storageKey, StorageScope.WORKSPACE, false)) { - return; + if (!allRecommendations.length || config.ignoreRecommendations || config.showRecommendationsOnlyOnDemand || this.storageService.getBoolean(storageKey, StorageScope.WORKSPACE, false)) { + return; + } + + return this.extensionsService.getInstalled(LocalExtensionType.User).done(local => { + const recommendations = allRecommendations + .filter(id => local.every(local => `${local.manifest.publisher.toLowerCase()}.${local.manifest.name.toLowerCase()}` !== id)); + + if (!recommendations.length) { + return TPromise.as(void 0); } - return this.extensionsService.getInstalled(LocalExtensionType.User).done(local => { - const recommendations = allRecommendations - .filter(id => local.every(local => `${local.manifest.publisher.toLowerCase()}.${local.manifest.name.toLowerCase()}` !== id)); + return new TPromise(c => { + this.notificationService.prompt( + Severity.Info, + localize('workspaceRecommended', "This workspace has extension recommendations."), + [{ + label: localize('installAll', "Install All"), + run: () => { + /* __GDPR__ + "extensionWorkspaceRecommendations:popup" : { + "userReaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'install' }); - if (!recommendations.length) { - return TPromise.as(void 0); - } + const installAllAction = this.instantiationService.createInstance(InstallWorkspaceRecommendedExtensionsAction, InstallWorkspaceRecommendedExtensionsAction.ID, localize('installAll', "Install All")); + installAllAction.run(); + installAllAction.dispose(); - return new TPromise(c => { - this.notificationService.prompt( - Severity.Info, - localize('workspaceRecommended', "This workspace has extension recommendations."), - [{ - label: localize('installAll', "Install All"), - run: () => { - /* __GDPR__ + c(void 0); + } + }, { + label: localize('showRecommendations', "Show Recommendations"), + run: () => { + /* __GDPR__ "extensionWorkspaceRecommendations:popup" : { "userReaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } - */ - this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'install' }); - - const installAllAction = this.instantiationService.createInstance(InstallWorkspaceRecommendedExtensionsAction, InstallWorkspaceRecommendedExtensionsAction.ID, localize('installAll', "Install All")); - installAllAction.run(); - installAllAction.dispose(); - - c(void 0); - } - }, { - label: localize('showRecommendations', "Show Recommendations"), - run: () => { - /* __GDPR__ - "extensionWorkspaceRecommendations:popup" : { - "userReaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'show' }); - - const showAction = this.instantiationService.createInstance(ShowRecommendedExtensionsAction, ShowRecommendedExtensionsAction.ID, localize('showRecommendations', "Show Recommendations")); - showAction.run(); - showAction.dispose(); + */ + this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'show' }); - c(void 0); - } - }, { - label: choiceNever, - isSecondary: true, - run: () => { - /* __GDPR__ - "extensionWorkspaceRecommendations:popup" : { - "userReaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'neverShowAgain' }); - this.storageService.store(storageKey, true, StorageScope.WORKSPACE); + const showAction = this.instantiationService.createInstance(ShowRecommendedExtensionsAction, ShowRecommendedExtensionsAction.ID, localize('showRecommendations', "Show Recommendations")); + showAction.run(); + showAction.dispose(); - c(void 0); - } - }], - () => { + c(void 0); + } + }, { + label: choiceNever, + isSecondary: true, + run: () => { /* __GDPR__ "extensionWorkspaceRecommendations:popup" : { "userReaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ - this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'cancelled' }); + this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'neverShowAgain' }); + this.storageService.store(storageKey, true, StorageScope.WORKSPACE); c(void 0); } - ); - }); + }], + () => { + /* __GDPR__ + "extensionWorkspaceRecommendations:popup" : { + "userReaction" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetryService.publicLog('extensionWorkspaceRecommendations:popup', { userReaction: 'cancelled' }); + + c(void 0); + } + ); }); }); } @@ -662,9 +736,9 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe if (exists && !foundExecutables.has(exeName)) { foundExecutables.add(exeName); (product.exeBasedExtensionTips[exeName]['recommendations'] || []) - .forEach(x => { - if (product.exeBasedExtensionTips[exeName]['friendlyName']) { - this._exeBasedRecommendations[x] = product.exeBasedExtensionTips[exeName]['friendlyName']; + .forEach(extensionId => { + if (product.exeBasedExtensionTips[exeName]['friendlyName'] && this.isExtensionAllowedToBeRecommended(extensionId)) { + this._exeBasedRecommendations[extensionId.toLowerCase()] = product.exeBasedExtensionTips[exeName]['friendlyName']; } }); } @@ -723,7 +797,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe && isNumber(storedRecommendationsJson['timestamp']) && storedRecommendationsJson['timestamp'] > 0 && (Date.now() - storedRecommendationsJson['timestamp']) / milliSecondsInADay < 14) { - this._dynamicWorkspaceRecommendations = storedRecommendationsJson['recommendations']; + this._dynamicWorkspaceRecommendations = storedRecommendationsJson['recommendations'].filter(id => this.isExtensionAllowedToBeRecommended(id)); /* __GDPR__ "dynamicWorkspaceRecommendations" : { "count" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, @@ -764,7 +838,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe for (let j = 0; j < allRecommendations.length && !foundRemote; j++) { if (Array.isArray(allRecommendations[j].remoteSet) && allRecommendations[j].remoteSet.indexOf(hashedRemotes[i]) > -1) { foundRemote = true; - this._dynamicWorkspaceRecommendations = allRecommendations[j].recommendations || []; + this._dynamicWorkspaceRecommendations = allRecommendations[j].recommendations.filter(id => this.isExtensionAllowedToBeRecommended(id)) || []; this.storageService.store(storageKey, JSON.stringify({ recommendations: this._dynamicWorkspaceRecommendations, timestamp: Date.now() @@ -812,6 +886,31 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe return Object.keys(result); } + ignoreExtensionRecommendation(extensionId: string): void { + /* __GDPR__ + "extensionsRecommendations:ignoreRecommendation" : { + "recommendationReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, + "extensionId": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight" } + } + */ + const reason = this.getAllRecommendationsWithReason()[extensionId.toLowerCase()]; + if (reason && reason.reasonId) { + this.telemetryService.publicLog('extensionsRecommendations:ignoreRecommendation', { id: extensionId, recommendationReason: reason.reasonId }); + } + + + this._globallyIgnoredRecommendations = distinct( + [...JSON.parse(this.storageService.get('extensionsAssistant/ignored_recommendations', StorageScope.GLOBAL, '[]')), extensionId.toLowerCase()] + .map(id => id.toLowerCase())); + + this.storageService.store('extensionsAssistant/ignored_recommendations', JSON.stringify(this._globallyIgnoredRecommendations), StorageScope.GLOBAL); + + this._allIgnoredRecommendations = distinct([...this._globallyIgnoredRecommendations, ...this._workspaceIgnoredRecommendations]); + + this.refilterAllRecommendations(); + this._onRecommendationChange.fire({ extensionId: extensionId, isRecommended: false }); + } + dispose() { this._disposables = dispose(this._disposables); } diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts index 10957613b75ad648fa53b6971d2ab1572cb17a5d..e98faebeb0034a634762fcadfc2f665a9794f905 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts @@ -1488,6 +1488,36 @@ export class InstallRecommendedExtensionAction extends Action { } } +export class IgnoreExtensionRecommendationAction extends Action { + + static readonly ID = 'extensions.ignore'; + + private static readonly Class = 'extension-action ignore octicon octicon-x'; + + private disposables: IDisposable[] = []; + extension: IExtension; + + constructor( + @IExtensionTipsService private extensionsTipsService: IExtensionTipsService, + ) { + super(IgnoreExtensionRecommendationAction.ID); + + this.class = IgnoreExtensionRecommendationAction.Class; + this.tooltip = localize('ignoreExtensionRecommendation', "Do not recommend this extension again"); + this.enabled = true; + } + + public run(): TPromise { + this.extensionsTipsService.ignoreExtensionRecommendation(this.extension.id); + return TPromise.as(null); + } + + dispose(): void { + super.dispose(); + this.disposables = dispose(this.disposables); + } +} + export class ShowRecommendedKeymapExtensionsAction extends Action { diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsList.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsList.ts index 32a607b78f119e62dbf4a70fc52d3c67548e2d97..5b83054fba525b8b00f7fb9d35f6ac595b1d7424 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsList.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsList.ts @@ -167,15 +167,12 @@ export class Renderer implements IPagedRenderer { data.icon.style.visibility = 'inherit'; } - data.root.setAttribute('aria-label', extension.displayName); - removeClass(data.root, 'recommended'); - - const extRecommendations = this.extensionTipsService.getAllRecommendationsWithReason(); - if (extRecommendations[extension.id.toLowerCase()]) { - data.root.setAttribute('aria-label', extension.displayName + '. ' + extRecommendations[extension.id.toLowerCase()].reasonText); - addClass(data.root, 'recommended'); - data.root.title = extRecommendations[extension.id.toLowerCase()].reasonText; - } + this.updateRecommendationStatus(extension, data); + data.extensionDisposables.push(this.extensionTipsService.onRecommendationChange(change => { + if (change.extensionId.toLowerCase() === extension.id.toLowerCase() && change.isRecommended === false) { + this.updateRecommendationStatus(extension, data); + } + })); data.name.textContent = extension.displayName; data.author.textContent = extension.publisherDisplayName; @@ -190,6 +187,20 @@ export class Renderer implements IPagedRenderer { }); } + private updateRecommendationStatus(extension: IExtension, data: ITemplateData) { + const extRecommendations = this.extensionTipsService.getAllRecommendationsWithReason(); + + if (!extRecommendations[extension.id.toLowerCase()]) { + data.root.setAttribute('aria-label', extension.displayName); + data.root.title = ''; + removeClass(data.root, 'recommended'); + } else { + data.root.setAttribute('aria-label', extension.displayName + '. ' + extRecommendations[extension.id]); + data.root.title = extRecommendations[extension.id.toLowerCase()].reasonText; + addClass(data.root, 'recommended'); + } + } + disposeTemplate(data: ITemplateData): void { data.disposables = dispose(data.disposables); } diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsViewlet.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsViewlet.ts index c230ffaad65f5c235fdd80cf372a6b6b3b2dcbfb..4ee01abef1d872e220026336108e8a38c67d0a4d 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsViewlet.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsViewlet.ts @@ -32,7 +32,7 @@ import { } from 'vs/workbench/parts/extensions/electron-browser/extensionsActions'; import { LocalExtensionType, IExtensionManagementService, IExtensionManagementServerService, IExtensionManagementServer } from 'vs/platform/extensionManagement/common/extensionManagement'; import { ExtensionsInput } from 'vs/workbench/parts/extensions/common/extensionsInput'; -import { ExtensionsListView, InstalledExtensionsView, EnabledExtensionsView, DisabledExtensionsView, RecommendedExtensionsView, WorkspaceRecommendedExtensionsView, BuiltInExtensionsView, BuiltInThemesExtensionsView, BuiltInBasicsExtensionsView, GroupByServerExtensionsView } from './extensionsViews'; +import { ExtensionsListView, InstalledExtensionsView, EnabledExtensionsView, DisabledExtensionsView, RecommendedExtensionsView, WorkspaceRecommendedExtensionsView, BuiltInExtensionsView, BuiltInThemesExtensionsView, BuiltInBasicsExtensionsView, GroupByServerExtensionsView, DefaultRecommendedExtensionsView } from './extensionsViews'; import { OpenGlobalSettingsAction } from 'vs/workbench/parts/preferences/browser/preferencesActions'; import { IProgressService } from 'vs/platform/progress/common/progress'; import { IEditorGroupsService } from 'vs/workbench/services/group/common/editorGroupsService'; @@ -182,7 +182,7 @@ export class ExtensionsViewletViewsContribution implements IWorkbenchContributio id: 'extensions.recommendedList', name: localize('recommendedExtensions', "Recommended"), container: VIEW_CONTAINER, - ctor: RecommendedExtensionsView, + ctor: DefaultRecommendedExtensionsView, when: ContextKeyExpr.and(ContextKeyExpr.not('searchExtensions'), ContextKeyExpr.has('defaultRecommendedExtensions')), weight: 70, order: 2, diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionsViews.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsViews.ts index 24240c479eab76a0b2ea8ffa92f2a8f0b2a8d30e..263423f63679eb5477d450c1b6487d450bf8e46b 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsViews.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsViews.ts @@ -55,7 +55,7 @@ export class ExtensionsListView extends ViewletPanel { @IExtensionService private extensionService: IExtensionService, @IExtensionsWorkbenchService private extensionsWorkbenchService: IExtensionsWorkbenchService, @IEditorService private editorService: IEditorService, - @IExtensionTipsService private tipsService: IExtensionTipsService, + @IExtensionTipsService protected tipsService: IExtensionTipsService, @IModeService private modeService: IModeService, @ITelemetryService private telemetryService: ITelemetryService, @IConfigurationService configurationService: IConfigurationService @@ -447,10 +447,10 @@ export class ExtensionsListView extends ViewletPanel { .then(recommendations => { const names = recommendations.filter(name => name.toLowerCase().indexOf(value) > -1); /* __GDPR__ - "extensionWorkspaceRecommendations:open" : { - "count" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true } - } - */ + "extensionWorkspaceRecommendations:open" : { + "count" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true } + } + */ this.telemetryService.publicLog('extensionWorkspaceRecommendations:open', { count: names.length }); if (!names.length) { @@ -645,15 +645,48 @@ export class BuiltInBasicsExtensionsView extends ExtensionsListView { } } +export class DefaultRecommendedExtensionsView extends ExtensionsListView { + + renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.disposables.push(this.tipsService.onRecommendationChange(() => { + this.show(''); + })); + } + + async show(query: string): TPromise> { + return super.show('@recommended:all'); + } +} + export class RecommendedExtensionsView extends ExtensionsListView { + + renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.disposables.push(this.tipsService.onRecommendationChange(() => { + this.show(''); + })); + } + async show(query: string): TPromise> { - return super.show(!query.trim() ? '@recommended:all' : '@recommended'); + return super.show('@recommended'); } } export class WorkspaceRecommendedExtensionsView extends ExtensionsListView { + + renderBody(container: HTMLElement): void { + super.renderBody(container); + + this.disposables.push(this.tipsService.onRecommendationChange(() => { + this.show(''); + })); + } + renderHeader(container: HTMLElement): void { super.renderHeader(container); diff --git a/src/vs/workbench/parts/extensions/electron-browser/media/extensionActions.css b/src/vs/workbench/parts/extensions/electron-browser/media/extensionActions.css index 46757df9d56e7191b51b3828031a3fba8dcb3211..b33e9f8095a1af4a5431d1ce5f32c19fc7324900 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/media/extensionActions.css +++ b/src/vs/workbench/parts/extensions/electron-browser/media/extensionActions.css @@ -71,4 +71,29 @@ .hc-black .extensions-viewlet>.extensions .extension>.details>.footer>.monaco-action-bar .action-item .action-label.extension-action.manage, .vs-dark .extensions-viewlet>.extensions .extension>.details>.footer>.monaco-action-bar .action-item .action-label.extension-action.manage { background: url('manage-inverse.svg') center center no-repeat; +} + +.extension-editor > .header.recommended > .details > .recommendation > .monaco-action-bar .actions-container { + justify-content: flex-start; +} + +.extension-editor > .header.recommended > .details > .recommendation > .monaco-action-bar .action-item .action-label.extension-action.ignore { + height: 13px; + width: 8px; + border: none; + outline-offset: 0; + margin-left: 6px; + padding-left: 0; + margin-top: 3px; + background-color: transparent; + color: hsl(0, 66%, 50%); +} + +.hc-black .extension-editor > .header.recommended > .details > .recommendation > .monaco-action-bar .action-item .action-label.extension-action.ignore, +.vs-dark .extension-editor > .header.recommended > .details > .recommendation > .monaco-action-bar .action-item .action-label.extension-action.ignore { + color: hsla(0, 66%, 77%, 1); +} + +.extension-editor > .header.recommended > .details > .recommendation > .monaco-action-bar .action-item .action-label.extension-action.ignore:hover { + filter: brightness(.8); } \ No newline at end of file diff --git a/src/vs/workbench/parts/extensions/electron-browser/media/extensionEditor.css b/src/vs/workbench/parts/extensions/electron-browser/media/extensionEditor.css index 42e53acdd0948eff4731298808d27cf8be43e4fc..f26c5200977bbb9f14498eb48cd70de8313ea2bb 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/media/extensionEditor.css +++ b/src/vs/workbench/parts/extensions/electron-browser/media/extensionEditor.css @@ -20,7 +20,9 @@ font-size: 14px; } -.extension-editor > .header.recommended { +.extension-editor > .header.recommended, +.extension-editor > .header.recommendation-ignored +{ height: 140px; } @@ -128,16 +130,22 @@ padding: 1px 6px; } -.extension-editor > .header.recommended > .details > .recommendation { - display: none; -} -.extension-editor > .header.recommended > .details > .recommendation { - display: block; +.extension-editor > .header.recommended > .details > .recommendation, +.extension-editor > .header.recommendation-ignored > .details > .recommendation { + display: flex; margin-top: 2px; font-size: 13px; } +.extension-editor > .header.recommendation-ignored > .details > .recommendation > .recommendation-text { + font-style: italic; +} + +.extension-editor > .header.recommendation-ignored > .details > .recommendation > .monaco-action-bar { + display: none; +} + .extension-editor > .body { height: calc(100% - 168px); overflow: hidden; diff --git a/src/vs/workbench/parts/extensions/test/electron-browser/extensionsTipsService.test.ts b/src/vs/workbench/parts/extensions/test/electron-browser/extensionsTipsService.test.ts index 3f7af9a154cba0a28c43c334124c42ab40755b6a..ac659cbd8114d64c4672a5b2d346c093b29daed7 100644 --- a/src/vs/workbench/parts/extensions/test/electron-browser/extensionsTipsService.test.ts +++ b/src/vs/workbench/parts/extensions/test/electron-browser/extensionsTipsService.test.ts @@ -5,6 +5,7 @@ 'use strict'; +import * as sinon from 'sinon'; import * as assert from 'assert'; import * as path from 'path'; import * as fs from 'fs'; @@ -36,7 +37,7 @@ import { IPager } from 'vs/base/common/paging'; import { assign } from 'vs/base/common/objects'; import { getGalleryExtensionId } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { IStorageService } from 'vs/platform/storage/common/storage'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { IExtensionsWorkbenchService, ConfigurationKey } from 'vs/workbench/parts/extensions/common/extensions'; import { ExtensionManagementService } from 'vs/platform/extensionManagement/node/extensionManagementService'; import { ExtensionsWorkbenchService } from 'vs/workbench/parts/extensions/node/extensionsWorkbenchService'; @@ -243,27 +244,30 @@ suite('ExtensionsTipsService Test', () => { }); }); - teardown((done) => { + teardown(done => { (testObject).dispose(); (extensionsWorkbenchService).dispose(); if (parentResource) { extfs.del(parentResource, os.tmpdir(), () => { }, done); + } else { + done(); } }); - function setUpFolderWorkspace(folderName: string, recommendedExtensions: string[]): TPromise { + function setUpFolderWorkspace(folderName: string, recommendedExtensions: string[], ignoredRecommendations: string[] = []): TPromise { const id = uuid.generateUuid(); parentResource = path.join(os.tmpdir(), 'vsctests', id); - return setUpFolder(folderName, parentResource, recommendedExtensions); + return setUpFolder(folderName, parentResource, recommendedExtensions, ignoredRecommendations); } - function setUpFolder(folderName: string, parentDir: string, recommendedExtensions: string[]): TPromise { + function setUpFolder(folderName: string, parentDir: string, recommendedExtensions: string[], ignoredRecommendations: string[] = []): TPromise { const folderDir = path.join(parentDir, folderName); const workspaceSettingsDir = path.join(folderDir, '.vscode'); return mkdirp(workspaceSettingsDir, 493).then(() => { const configPath = path.join(workspaceSettingsDir, 'extensions.json'); fs.writeFileSync(configPath, JSON.stringify({ - 'recommendations': recommendedExtensions + 'recommendations': recommendedExtensions, + 'unwantedRecommendations': ignoredRecommendations, }, null, '\t')); const myWorkspace = testWorkspace(URI.from({ scheme: 'file', path: folderDir })); @@ -276,7 +280,7 @@ suite('ExtensionsTipsService Test', () => { function testNoPromptForValidRecommendations(recommendations: string[]) { return setUpFolderWorkspace('myFolder', recommendations).then(() => { testObject = instantiationService.createInstance(ExtensionTipsService); - return testObject.promptWorkspaceRecommendationsPromise.then(() => { + return testObject.loadRecommendationsPromise.then(() => { assert.equal(Object.keys(testObject.getAllRecommendationsWithReason()).length, recommendations.length); assert.ok(!prompted); }); @@ -286,7 +290,7 @@ suite('ExtensionsTipsService Test', () => { function testNoPromptOrRecommendationsForValidRecommendations(recommendations: string[]) { return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { testObject = instantiationService.createInstance(ExtensionTipsService); - assert.equal(!testObject.promptWorkspaceRecommendationsPromise, true); + assert.equal(!testObject.loadRecommendationsPromise, true); assert.ok(!prompted); return testObject.getWorkspaceRecommendations().then(() => { @@ -313,7 +317,7 @@ suite('ExtensionsTipsService Test', () => { test('ExtensionTipsService: Prompt for valid workspace recommendations', () => { return setUpFolderWorkspace('myFolder', mockTestData.recommendedExtensions).then(() => { testObject = instantiationService.createInstance(ExtensionTipsService); - return testObject.promptWorkspaceRecommendationsPromise.then(() => { + return testObject.loadRecommendationsPromise.then(() => { const recommendations = Object.keys(testObject.getAllRecommendationsWithReason()); assert.equal(recommendations.length, mockTestData.validRecommendedExtensions.length); @@ -345,7 +349,7 @@ suite('ExtensionsTipsService Test', () => { testConfigurationService.setUserConfiguration(ConfigurationKey, { showRecommendationsOnlyOnDemand: true }); return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { testObject = instantiationService.createInstance(ExtensionTipsService); - return testObject.promptWorkspaceRecommendationsPromise.then(() => { + return testObject.loadRecommendationsPromise.then(() => { assert.equal(Object.keys(testObject.getAllRecommendationsWithReason()).length, 0); assert.ok(!prompted); }); @@ -357,17 +361,152 @@ suite('ExtensionsTipsService Test', () => { return testNoPromptForValidRecommendations(mockTestData.validRecommendedExtensions); }); + test('ExtensionTipsService: No Recommendations of globally ignored recommendations', () => { + const storageGetterStub = (a, _, c) => { + const storedRecommendations = '["ms-vscode.csharp", "ms-python.python", "eg2.tslint"]'; + const ignoredRecommendations = '["ms-vscode.csharp", "mockpublisher2.mockextension2"]'; // ignore a stored recommendation and a workspace recommendation. + if (a === 'extensionsAssistant/recommendations') { return storedRecommendations; } + if (a === 'extensionsAssistant/ignored_recommendations') { return ignoredRecommendations; } + return c; + }; + + instantiationService.stub(IStorageService, { + get: storageGetterStub, + getBoolean: (a, _, c) => a === 'extensionsAssistant/workspaceRecommendationsIgnore' || c + }); + + return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { + testObject = instantiationService.createInstance(ExtensionTipsService); + return testObject.loadRecommendationsPromise.then(() => { + const recommendations = testObject.getAllRecommendationsWithReason(); + assert.ok(!recommendations['ms-vscode.csharp']); // stored recommendation that has been globally ignored + assert.ok(recommendations['ms-python.python']); // stored recommendation + assert.ok(recommendations['mockpublisher1.mockextension1']); // workspace recommendation + assert.ok(!recommendations['mockpublisher2.mockextension2']); // workspace recommendation that has been globally ignored + }); + }); + }); + + test('ExtensionTipsService: No Recommendations of workspace ignored recommendations', () => { + const ignoredRecommendations = ['ms-vscode.csharp', 'mockpublisher2.mockextension2']; // ignore a stored recommendation and a workspace recommendation. + const storedRecommendations = '["ms-vscode.csharp", "ms-python.python"]'; + instantiationService.stub(IStorageService, { + get: (a, b, c) => a === 'extensionsAssistant/recommendations' ? storedRecommendations : c, + getBoolean: (a, _, c) => a === 'extensionsAssistant/workspaceRecommendationsIgnore' || c + }); + + return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions, ignoredRecommendations).then(() => { + testObject = instantiationService.createInstance(ExtensionTipsService); + return testObject.loadRecommendationsPromise.then(() => { + const recommendations = testObject.getAllRecommendationsWithReason(); + assert.ok(!recommendations['ms-vscode.csharp']); // stored recommendation that has been workspace ignored + assert.ok(recommendations['ms-python.python']); // stored recommendation + assert.ok(recommendations['mockpublisher1.mockextension1']); // workspace recommendation + assert.ok(!recommendations['mockpublisher2.mockextension2']); // workspace recommendation that has been workspace ignored + }); + }); + }); + + test('ExtensionTipsService: Able to retrieve collection of all ignored recommendations', () => { + + const storageGetterStub = (a, _, c) => { + const storedRecommendations = '["ms-vscode.csharp", "ms-python.python"]'; + const globallyIgnoredRecommendations = '["mockpublisher2.mockextension2"]'; // ignore a workspace recommendation. + if (a === 'extensionsAssistant/recommendations') { return storedRecommendations; } + if (a === 'extensionsAssistant/ignored_recommendations') { return globallyIgnoredRecommendations; } + return c; + }; + + const workspaceIgnoredRecommendations = ['ms-vscode.csharp']; // ignore a stored recommendation and a workspace recommendation. + instantiationService.stub(IStorageService, { + get: storageGetterStub, + getBoolean: (a, _, c) => a === 'extensionsAssistant/workspaceRecommendationsIgnore' || c + }); + + return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions, workspaceIgnoredRecommendations).then(() => { + testObject = instantiationService.createInstance(ExtensionTipsService); + return testObject.loadRecommendationsPromise.then(() => { + const recommendations = testObject.getAllIgnoredRecommendations(); + assert.deepStrictEqual(recommendations, + { + global: ['mockpublisher2.mockextension2'], + workspace: ['ms-vscode.csharp'] + }); + }); + }); + }); + + test('ExtensionTipsService: Able to dynamically ignore global recommendations', () => { + const storageGetterStub = (a, _, c) => { + const storedRecommendations = '["ms-vscode.csharp", "ms-python.python"]'; + const globallyIgnoredRecommendations = '["mockpublisher2.mockextension2"]'; // ignore a workspace recommendation. + if (a === 'extensionsAssistant/recommendations') { return storedRecommendations; } + if (a === 'extensionsAssistant/ignored_recommendations') { return globallyIgnoredRecommendations; } + return c; + }; + + instantiationService.stub(IStorageService, { + get: storageGetterStub, + store: () => { }, + getBoolean: (a, _, c) => a === 'extensionsAssistant/workspaceRecommendationsIgnore' || c + }); + + return setUpFolderWorkspace('myFolder', mockTestData.validRecommendedExtensions).then(() => { + testObject = instantiationService.createInstance(ExtensionTipsService); + return testObject.loadRecommendationsPromise.then(() => { + const recommendations = testObject.getAllIgnoredRecommendations(); + assert.deepStrictEqual(recommendations, + { + global: ['mockpublisher2.mockextension2'], + workspace: [] + }); + return testObject.ignoreExtensionRecommendation('mockpublisher1.mockextension1'); + }).then(() => { + const recommendations = testObject.getAllIgnoredRecommendations(); + assert.deepStrictEqual(recommendations, + { + global: ['mockpublisher2.mockextension2', 'mockpublisher1.mockextension1'], + workspace: [] + }); + }); + }); + }); + + test('test global extensions are modified and recommendation change event is fired when an extension is ignored', () => { + const storageSetterTarget = sinon.spy(); + const changeHandlerTarget = sinon.spy(); + const ignoredExtensionId = 'Some.Extension'; + instantiationService.stub(IStorageService, { + get: (a, b, c) => a === 'extensionsAssistant/ignored_recommendations' ? '["ms-vscode.vscode"]' : c, + store: (...args) => { + storageSetterTarget(...args); + } + }); + + return setUpFolderWorkspace('myFolder', []).then(() => { + testObject = instantiationService.createInstance(ExtensionTipsService); + testObject.onRecommendationChange(changeHandlerTarget); + testObject.ignoreExtensionRecommendation(ignoredExtensionId); + + assert.ok(changeHandlerTarget.calledOnce); + assert.ok(changeHandlerTarget.getCall(0).calledWithMatch({ extensionId: 'Some.Extension', isRecommended: false })); + assert.ok(storageSetterTarget.calledWithExactly('extensionsAssistant/ignored_recommendations', `["ms-vscode.vscode","${ignoredExtensionId.toLowerCase()}"]`, StorageScope.GLOBAL)); + }); + }); + test('ExtensionTipsService: Get file based recommendations from storage (old format)', () => { const storedRecommendations = '["ms-vscode.csharp", "ms-python.python", "eg2.tslint"]'; instantiationService.stub(IStorageService, { get: (a, b, c) => a === 'extensionsAssistant/recommendations' ? storedRecommendations : c }); return setUpFolderWorkspace('myFolder', []).then(() => { testObject = instantiationService.createInstance(ExtensionTipsService); - const recommendations = testObject.getFileBasedRecommendations(); - assert.equal(recommendations.length, 2); - assert.ok(recommendations.indexOf('ms-vscode.csharp') > -1); // stored recommendation that exists in product.extensionTips - assert.ok(recommendations.indexOf('ms-python.python') > -1); // stored recommendation that exists in product.extensionImportantTips - assert.ok(recommendations.indexOf('eg2.tslint') === -1); // stored recommendation that is no longer in neither product.extensionTips nor product.extensionImportantTips + return testObject.loadRecommendationsPromise.then(() => { + const recommendations = testObject.getFileBasedRecommendations(); + assert.equal(recommendations.length, 2); + assert.ok(recommendations.indexOf('ms-vscode.csharp') > -1); // stored recommendation that exists in product.extensionTips + assert.ok(recommendations.indexOf('ms-python.python') > -1); // stored recommendation that exists in product.extensionImportantTips + assert.ok(recommendations.indexOf('eg2.tslint') === -1); // stored recommendation that is no longer in neither product.extensionTips nor product.extensionImportantTips + }); }); }); @@ -380,12 +519,14 @@ suite('ExtensionsTipsService Test', () => { return setUpFolderWorkspace('myFolder', []).then(() => { testObject = instantiationService.createInstance(ExtensionTipsService); - const recommendations = testObject.getFileBasedRecommendations(); - assert.equal(recommendations.length, 2); - assert.ok(recommendations.indexOf('ms-vscode.csharp') > -1); // stored recommendation that exists in product.extensionTips - assert.ok(recommendations.indexOf('ms-python.python') > -1); // stored recommendation that exists in product.extensionImportantTips - assert.ok(recommendations.indexOf('eg2.tslint') === -1); // stored recommendation that is no longer in neither product.extensionTips nor product.extensionImportantTips - assert.ok(recommendations.indexOf('lukehoban.Go') === -1); //stored recommendation that is older than a week + return testObject.loadRecommendationsPromise.then(() => { + const recommendations = testObject.getFileBasedRecommendations(); + assert.equal(recommendations.length, 2); + assert.ok(recommendations.indexOf('ms-vscode.csharp') > -1); // stored recommendation that exists in product.extensionTips + assert.ok(recommendations.indexOf('ms-python.python') > -1); // stored recommendation that exists in product.extensionImportantTips + assert.ok(recommendations.indexOf('eg2.tslint') === -1); // stored recommendation that is no longer in neither product.extensionTips nor product.extensionImportantTips + assert.ok(recommendations.indexOf('lukehoban.Go') === -1); //stored recommendation that is older than a week + }); }); }); });