From 442e5e202a89c1f8efe642c89c0554cab1f15a9e Mon Sep 17 00:00:00 2001 From: Jackson Kearl Date: Tue, 19 Jun 2018 11:53:23 -0700 Subject: [PATCH] Add ability to ignore recommendations both globally and at a per-workspace level (#51941) * WIP * Initail filtering and pulling filter list logic. * Imporive typing and naming * Remove the significant duplication * Bug fixes. * Fix bug where arrow functions dont maintain `this` in point free style * WIP on extension ignoring UI. * UI for global ignores. * Add "unwantedRecommendations" to extensions.json template and intelisense * Notify of workspace non-recommended extensions * Wording of extensions.json template * More UI for ignore button. * Use seprate notification channel of recommendation changes. * Reload search when recommended extensions changes * Tests for ExtensionTipsService * Test extensions workbench service * Naming and add default vaule to workspace settings * Initial revisions * Global ignore need not call workspace refresh * Fix build issues * Skip refreshing workspace twice * WIP * WIP * Reduce file accesses. Remove bug causing all open editors to show exrtension as ignored * Fix some of the build issues. * Hackish thing that fixes the test. * Rename id to extensionId in RecommendationChangeNotification * updateRecommendedness ?? * Not needed * Remove point free style thing * Simplify * naming * remove extrenous getWrokspaceRecommendations call * Casing * Gracefull handle missing 'extensions' field in multiroot project config * Refresh recommendation views on recommendation change * more case sensitivity stuff * naming/refactroing * Simplify return types * Wording * Add telemetry * guard telemetry service --- .../common/extensionManagement.ts | 18 + src/vs/platform/node/product.ts | 2 +- .../common/extensionsFileTemplate.ts | 18 +- .../electron-browser/extensionEditor.ts | 47 +- .../electron-browser/extensionTipsService.ts | 409 +++++++++++------- .../electron-browser/extensionsActions.ts | 30 ++ .../electron-browser/extensionsList.ts | 29 +- .../electron-browser/extensionsViewlet.ts | 4 +- .../electron-browser/extensionsViews.ts | 45 +- .../media/extensionActions.css | 25 ++ .../media/extensionEditor.css | 20 +- .../extensionsTipsService.test.ts | 183 +++++++- 12 files changed, 619 insertions(+), 211 deletions(-) diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 0a35c0f0cd1..12e65007418 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 685ccc5d374..c60c4d01ec6 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 fc81cb063d1..a6d988dfb7c 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 e42c43d3749..3440e74a6dd 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 71cce68cb0b..9c3312d585e 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 10957613b75..e98faebeb00 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 32a607b78f1..5b83054fba5 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 c230ffaad65..4ee01abef1d 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 24240c479ea..263423f6367 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 46757df9d56..b33e9f8095a 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 42e53acdd09..f26c5200977 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 3f7af9a154c..ac659cbd811 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 + }); }); }); }); -- GitLab