From 5eea6bcc4db9fa6159714a7d32667c8248b84344 Mon Sep 17 00:00:00 2001 From: Ronald Rey Date: Sun, 24 Jun 2018 15:21:21 -0400 Subject: [PATCH] Add "Recommend" action to extension viewlet (#50419) * Add "Recommend" action to extension viewlet Related to #13456 * Address PR requested changes * Change functionality and UX - Button goes from "Add to workspace recommendations" (clickable) to "Adding to workspace recommendations..." (unclickable) to "Added to workspace recommendations" (unclickable). - It is only visible if the current extension is installed and there's at least one folder in the root that doesn't have it recommended yet. In other words, if it's already recommended in every folder of the root, it is not visible. - In a single-root setup, it's immediately added to the recommendations. - In a multi-root setup, a quick-pick is displayed with only the folders where it is not yet recommended available as options. - In a multi-root setup, the button will go back to "Add to workspace recommendations" (clickable) instead of the unclickable state if there are still folders remaining where the current extension is not yet recommended. - An error or success notification is displayed after the work is done. - Configuration files are created if don't exist, modified otherwise. * Don't show the button if ext is recommended in ANY folder In the previous commit, the button would be displayed as long as there was at least one folder in the workspace where the extension wasn't recommended. Now, the button will be displayed only if the current extension is not recommended in any of the folders, as suggested in https://github.com/Microsoft/vscode/pull/50419#issuecomment-398216711 * Improvements based on PR suggestions - Lowercasing the extension ID before comparing - Directly styling & labeling the button on the `run` command after work is done instead of calling `update`. - Fix & delete unnecessary styles * Expose feature as command instead of button After a discussion with @ramya-rao-a and other members of the team, it was decided that it wasn't reasonable to have a dedicated button for a feature that's rarely used in such a visible place. So instead, the action will be exposed as a command that will only be available when there's an extension open in the editor area. In contrast with the previous implementation, this has the added benefit of allowing the user to use this action to recommend uninstalled extensions as well. * Simplify conditions for AddToRecommendations action * Move comments so that they dont get erased when recommendations are added programatically * Remove from unwanted before adding to recommendations * Array improvements * Friendly error msg --- .../common/extensionsFileTemplate.ts | 11 +- .../electron-browser/extensionsActions.ts | 141 +++++++++++++++++- .../extensionsActions.test.ts | 6 +- 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts b/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts index a6d988dfb7ccb..1147e42ff1bf8 100644 --- a/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts +++ b/src/vs/workbench/parts/extensions/common/extensionsFileTemplate.ts @@ -38,16 +38,15 @@ export const ExtensionsConfigurationSchema: IJSONSchema = { export const ExtensionsConfigurationInitialContent: string = [ '{', - '\t// See http://go.microsoft.com/fwlink/?LinkId=827846', - '\t// for the documentation about the extensions.json format', + '\t// See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.', + '\t// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp', + '', + '\t// List of extensions which should be recommended for users of this workspace.', '\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// 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"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/extensionsActions.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts index 5e6416dd6a5fe..1fc7c52ca0844 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionsActions.ts @@ -18,7 +18,7 @@ import { IContextMenuService } from 'vs/platform/contextview/browser/contextView import { IDisposable, dispose, Disposable } from 'vs/base/common/lifecycle'; import { IExtension, ExtensionState, IExtensionsWorkbenchService, VIEWLET_ID, IExtensionsViewlet, AutoUpdateConfigurationKey } from 'vs/workbench/parts/extensions/common/extensions'; import { ExtensionsConfigurationInitialContent } from 'vs/workbench/parts/extensions/common/extensionsFileTemplate'; -import { LocalExtensionType, IExtensionEnablementService, IExtensionTipsService, EnablementState, ExtensionsLabel, IExtensionManagementServer, IExtensionManagementServerService, IGalleryExtension, ILocalExtension } from 'vs/platform/extensionManagement/common/extensionManagement'; +import { LocalExtensionType, IExtensionEnablementService, IExtensionTipsService, EnablementState, ExtensionsLabel, IExtensionManagementServer, IExtensionManagementServerService, IGalleryExtension, ILocalExtension, IExtensionsConfigContent } from 'vs/platform/extensionManagement/common/extensionManagement'; import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { ToggleViewletAction } from 'vs/workbench/browser/viewlet'; @@ -49,6 +49,7 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment' import { IQuickOpenService, IPickOpenEntry } from 'vs/platform/quickOpen/common/quickOpen'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IEditorGroupsService } from 'vs/workbench/services/group/common/editorGroupsService'; +import { ExtensionsInput } from 'vs/workbench/parts/extensions/common/extensionsInput'; import product from 'vs/platform/node/product'; const promptDownloadManually = (extension: IGalleryExtension, message: string, instantiationService: IInstantiationService, notificationService: INotificationService, openerService: IOpenerService) => { @@ -1709,21 +1710,26 @@ export class ConfigureRecommendedExtensionsCommandsContributor extends Disposabl private workspaceContextKey = new RawContextKey('workspaceRecommendations', true); private workspaceFolderContextKey = new RawContextKey('workspaceFolderRecommendations', true); + private addToRecommendationsContextKey = new RawContextKey('addToRecommendations', false); constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IWorkspaceContextService workspaceContextService: IWorkspaceContextService + @IWorkspaceContextService workspaceContextService: IWorkspaceContextService, + @IEditorService editorService: IEditorService ) { super(); const boundWorkspaceContextKey = this.workspaceContextKey.bindTo(contextKeyService); boundWorkspaceContextKey.set(workspaceContextService.getWorkbenchState() === WorkbenchState.WORKSPACE); this._register(workspaceContextService.onDidChangeWorkbenchState(() => boundWorkspaceContextKey.set(workspaceContextService.getWorkbenchState() === WorkbenchState.WORKSPACE))); - const boundWorkspaceFolderContextKey = this.workspaceFolderContextKey.bindTo(contextKeyService); boundWorkspaceFolderContextKey.set(workspaceContextService.getWorkspace().folders.length > 0); this._register(workspaceContextService.onDidChangeWorkspaceFolders(() => boundWorkspaceFolderContextKey.set(workspaceContextService.getWorkspace().folders.length > 0))); + const boundAddToRecommendationsContextKey = this.addToRecommendationsContextKey.bindTo(contextKeyService); + boundAddToRecommendationsContextKey.set(editorService.activeEditor instanceof ExtensionsInput); + this._register(editorService.onDidActiveEditorChange(() => boundAddToRecommendationsContextKey.set(editorService.activeEditor instanceof ExtensionsInput))); + this.registerCommands(); } @@ -1749,7 +1755,19 @@ export class ConfigureRecommendedExtensionsCommandsContributor extends Disposabl }, when: this.workspaceFolderContextKey }); + + CommandsRegistry.registerCommand(AddToWorkspaceRecommendationsAction.ID, serviceAccesor => { + serviceAccesor.get(IInstantiationService).createInstance(AddToWorkspaceRecommendationsAction, AddToWorkspaceRecommendationsAction.ID, AddToWorkspaceRecommendationsAction.LABEL).run(); + }); + MenuRegistry.appendMenuItem(MenuId.CommandPalette, { + command: { + id: AddToWorkspaceRecommendationsAction.ID, + title: `${ExtensionsLabel}: ${AddToWorkspaceRecommendationsAction.LABEL}` + }, + when: this.addToRecommendationsContextKey + }); } + } interface IExtensionsContent { @@ -1763,7 +1781,7 @@ export abstract class AbstractConfigureRecommendedExtensionsAction extends Actio label: string, @IWorkspaceContextService protected contextService: IWorkspaceContextService, @IFileService private fileService: IFileService, - @IEditorService private editorService: IEditorService, + @IEditorService protected editorService: IEditorService, @IJSONEditingService private jsonEditingService: IJSONEditingService, @ITextModelService private textModelResolverService: ITextModelService ) { @@ -1797,6 +1815,57 @@ export abstract class AbstractConfigureRecommendedExtensionsAction extends Actio })); } + protected addRecommendedExtensionToFolder(extensionsFileResource: URI, extensionId: string): TPromise { + return this.getOrCreateExtensionsFile(extensionsFileResource) + .then(({ content }) => { + const extensionIdLowerCase = extensionId.toLowerCase(); + const jsonContent: IExtensionsConfigContent = json.parse(content) || {}; + const folderRecommendations = jsonContent.recommendations || []; + + if (folderRecommendations.some(e => e.toLowerCase() === extensionIdLowerCase)) { + return TPromise.as(null); + } + folderRecommendations.push(extensionId); + + const folderUnwantedRecommedations = jsonContent.unwantedRecommendations || []; + let index = -1; + for (let i = 0; i < folderUnwantedRecommedations.length; i++) { + if (folderUnwantedRecommedations[i].toLowerCase() === extensionIdLowerCase) { + index = i; + break; + } + } + + let removeFromUnwantedPromise = TPromise.wrap(null); + if (index > -1) { + folderUnwantedRecommedations.splice(index, 1); + removeFromUnwantedPromise = this.jsonEditingService.write(extensionsFileResource, + { + key: 'unwantedRecommendations', + value: folderUnwantedRecommedations + }, + true); + } + + return removeFromUnwantedPromise.then(() => + this.jsonEditingService.write(extensionsFileResource, + { + key: 'recommendations', + value: folderRecommendations + }, + true) + ); + }); + } + + protected getFolderRecommendedExtensions(extensionsFileResource: URI): TPromise { + return this.fileService.resolveContent(extensionsFileResource) + .then(content => { + const folderRecommendations = (json.parse(content.value)); + return folderRecommendations.recommendations || []; + }, err => []); + } + private getOrUpdateWorkspaceConfigurationFile(workspaceConfigurationFile: URI): TPromise { return this.fileService.resolveContent(workspaceConfigurationFile) .then(content => { @@ -1927,6 +1996,70 @@ export class ConfigureWorkspaceFolderRecommendedExtensionsAction extends Abstrac } } +export class AddToWorkspaceRecommendationsAction extends AbstractConfigureRecommendedExtensionsAction { + + static readonly ID = 'workbench.extensions.action.addToWorkspaceRecommendations'; + static LABEL = localize('addToWorkspaceRecommendations', "Add to workspace recommendations"); + + constructor( + id: string, + label: string, + @IFileService fileService: IFileService, + @IWorkspaceContextService contextService: IWorkspaceContextService, + @IEditorService editorService: IEditorService, + @IJSONEditingService jsonEditingService: IJSONEditingService, + @ITextModelService textModelResolverService: ITextModelService, + @ICommandService private commandService: ICommandService, + @INotificationService private notificationService: INotificationService + ) { + super( + id, + label, + contextService, + fileService, + editorService, + jsonEditingService, + textModelResolverService + ); + } + + run(): TPromise { + if (!(this.editorService.activeEditor instanceof ExtensionsInput) || !this.editorService.activeEditor.extension) { + return TPromise.as(null); + } + const folders = this.contextService.getWorkspace().folders; + if (!folders || !folders.length) { + this.notificationService.info(localize('AddToWorkspaceRecommendations.noWorkspace', 'There is no workspace open to add recommendations.')); + return TPromise.as(null); + } + + const extensionId = this.editorService.activeEditor.extension.id; + const pickFolderPromise = folders.length === 1 + ? TPromise.as(folders[0]) + : this.commandService.executeCommand(PICK_WORKSPACE_FOLDER_COMMAND_ID); + return pickFolderPromise + .then(workspaceFolder => { + if (!workspaceFolder) { + return TPromise.as(null); + } + const configurationFile = workspaceFolder.toResource(paths.join('.vscode', 'extensions.json')); + return this.getFolderRecommendedExtensions(configurationFile).then(recommendations => { + const extensionIdLowerCase = extensionId.toLowerCase(); + if (recommendations.some(e => e.toLowerCase() === extensionIdLowerCase)) { + this.notificationService.info(localize('AddToWorkspaceRecommendations.alreadyExists', 'This extension is already present in workspace recommendations.')); + return TPromise.as(null); + } + + return this.addRecommendedExtensionToFolder(configurationFile, extensionId).then(() => { + this.notificationService.info(localize('AddToWorkspaceRecommendations.success', 'The extension was successfully added to workspace recommendations.')); + }, err => { + this.notificationService.error(localize('AddToWorkspaceRecommendations.failure', 'Failed to write to extensions.json. {0}', err)); + }); + }); + }); + } +} + export class MaliciousStatusLabelAction extends Action { private static readonly Class = 'malicious-status'; diff --git a/src/vs/workbench/parts/extensions/test/electron-browser/extensionsActions.test.ts b/src/vs/workbench/parts/extensions/test/electron-browser/extensionsActions.test.ts index 55118300c7ac8..68779a8ef72e7 100644 --- a/src/vs/workbench/parts/extensions/test/electron-browser/extensionsActions.test.ts +++ b/src/vs/workbench/parts/extensions/test/electron-browser/extensionsActions.test.ts @@ -1197,6 +1197,10 @@ suite('ExtensionsActions Test', () => { }); }); + test(`RecommendToFolderAction`, () => { + // TODO: Implement test + }); + function aLocalExtension(name: string = 'someext', manifest: any = {}, properties: any = {}): ILocalExtension { const localExtension = Object.create({ manifest: {} }); assign(localExtension, { type: LocalExtensionType.User, manifest: {}, location: URI.file(`pub.${name}`) }, properties); @@ -1219,4 +1223,4 @@ suite('ExtensionsActions Test', () => { return { firstPage: objects, total: objects.length, pageSize: objects.length, getPage: () => null }; } -}); \ No newline at end of file +});