From f4074d885d8ba4087c392d23502b30d39a5ee4f2 Mon Sep 17 00:00:00 2001 From: seantan22 Date: Thu, 11 Mar 2021 13:46:33 -0500 Subject: [PATCH] VSX: Add 'Install Another Version...' Command What It Does - Supports the ability to install any compatible version of an user-installed extension, provided that the extension is available in the Open VSX Registry. How To Test 1. Open the extensions view and locate the `Installed` view of all user-installed extensions. 2. Pick an extension --> Click the `manage` gear icon --> Click `Install Another Version...` command. Note that the command is disabled fo any extension not available in the Open VSX Registry. 3. Select a compatible version to install from the Quick Pick dropdown. 4. Observe that the previous version is uninstalled and replaced with the newly selected version. Signed-off-by: seantan22 --- .../plugin-ext/src/common/plugin-protocol.ts | 8 ++- .../src/main/node/plugin-deployer-impl.ts | 21 +++--- .../src/main/node/plugin-server-handler.ts | 10 +-- .../src/browser/vsx-extension.tsx | 18 +++-- .../browser/vsx-extensions-contribution.ts | 71 +++++++++++++++++-- .../src/browser/vsx-extensions-model.ts | 21 ++++-- .../src/common/vsx-registry-api.ts | 5 +- .../src/node/vsx-extension-resolver.ts | 38 +++++++--- 8 files changed, 145 insertions(+), 47 deletions(-) diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 2c1b7f7f179bb..72344438a6991 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -321,7 +321,7 @@ export interface PluginDeployerResolver { accept(pluginSourceId: string): boolean; - resolve(pluginResolverContext: PluginDeployerResolverContext): Promise; + resolve(pluginResolverContext: PluginDeployerResolverContext, options?: PluginDeployOptions): Promise; } @@ -835,6 +835,10 @@ export interface WorkspaceStorageKind { export type GlobalStorageKind = undefined; export type PluginStorageKind = GlobalStorageKind | WorkspaceStorageKind; +export interface PluginDeployOptions { + version: string; +} + /** * The JSON-RPC workspace interface. */ @@ -847,7 +851,7 @@ export interface PluginServer { * * @param type whether a plugin is installed by a system or a user, defaults to a user */ - deploy(pluginEntry: string, type?: PluginType): Promise; + deploy(pluginEntry: string, type?: PluginType, options?: PluginDeployOptions): Promise; undeploy(pluginId: string): Promise; diff --git a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts index 80ef8372eff5d..fd4fe76adef24 100644 --- a/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts +++ b/packages/plugin-ext/src/main/node/plugin-deployer-impl.ts @@ -21,7 +21,7 @@ import { PluginDeployerResolver, PluginDeployerFileHandler, PluginDeployerDirectoryHandler, PluginDeployerEntry, PluginDeployer, PluginDeployerParticipant, PluginDeployerStartContext, PluginDeployerResolverInit, PluginDeployerFileHandlerContext, - PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginDeployerHandler, PluginType + PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginDeployerHandler, PluginType, PluginDeployOptions } from '../../common/plugin-protocol'; import { PluginDeployerEntryImpl } from './plugin-deployer-entry-impl'; import { @@ -132,14 +132,15 @@ export class PluginDeployerImpl implements PluginDeployer { } } - async deploy(pluginEntry: string, type: PluginType = PluginType.System): Promise { + async deploy(pluginEntry: string, type: PluginType = PluginType.System, options?: PluginDeployOptions): Promise { const startDeployTime = performance.now(); - await this.deployMultipleEntries([pluginEntry], type); + await this.deployMultipleEntries([pluginEntry], type, options); this.logMeasurement('Deploy plugin entry', startDeployTime); } - protected async deployMultipleEntries(pluginEntries: ReadonlyArray, type: PluginType = PluginType.System): Promise { - const pluginsToDeploy = await this.resolvePlugins(pluginEntries, type); + protected async deployMultipleEntries(pluginEntries: ReadonlyArray, type: PluginType = PluginType.System, options?: PluginDeployOptions): Promise { + let pluginsToDeploy = []; + pluginsToDeploy = await this.resolvePlugins(pluginEntries, type, options); await this.deployPlugins(pluginsToDeploy); } @@ -155,7 +156,7 @@ export class PluginDeployerImpl implements PluginDeployer { * ]); * ``` */ - async resolvePlugins(pluginEntries: ReadonlyArray, type: PluginType): Promise { + async resolvePlugins(pluginEntries: ReadonlyArray, type: PluginType, options?: PluginDeployOptions): Promise { const visited = new Set(); const pluginsToDeploy = new Map(); @@ -175,7 +176,8 @@ export class PluginDeployerImpl implements PluginDeployer { queue = []; await Promise.all(workload.map(async current => { try { - const pluginDeployerEntries = await this.resolvePlugin(current, type); + let pluginDeployerEntries = []; + pluginDeployerEntries = await this.resolvePlugin(current, type, options); await this.applyFileHandlers(pluginDeployerEntries); await this.applyDirectoryFileHandlers(pluginDeployerEntries); for (const deployerEntry of pluginDeployerEntries) { @@ -273,16 +275,15 @@ export class PluginDeployerImpl implements PluginDeployer { /** * Check a plugin ID see if there are some resolvers that can handle it. If there is a matching resolver, then we resolve the plugin */ - public async resolvePlugin(pluginId: string, type: PluginType = PluginType.System): Promise { + public async resolvePlugin(pluginId: string, type: PluginType = PluginType.System, options?: PluginDeployOptions): Promise { const pluginDeployerEntries: PluginDeployerEntry[] = []; const foundPluginResolver = this.pluginResolvers.find(pluginResolver => pluginResolver.accept(pluginId)); // there is a resolver for the input if (foundPluginResolver) { - // create context object const context = new PluginDeployerResolverContextImpl(foundPluginResolver, pluginId); - await foundPluginResolver.resolve(context); + await foundPluginResolver.resolve(context, options); context.getPlugins().forEach(entry => { entry.type = type; diff --git a/packages/plugin-ext/src/main/node/plugin-server-handler.ts b/packages/plugin-ext/src/main/node/plugin-server-handler.ts index c28a58f1b4a60..89c4dcc490287 100644 --- a/packages/plugin-ext/src/main/node/plugin-server-handler.ts +++ b/packages/plugin-ext/src/main/node/plugin-server-handler.ts @@ -18,7 +18,7 @@ import { injectable, inject } from '@theia/core/shared/inversify'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { PluginDeployerImpl } from './plugin-deployer-impl'; import { PluginsKeyValueStorage } from './plugins-key-value-storage'; -import { PluginServer, PluginDeployer, PluginStorageKind, PluginType } from '../../common/plugin-protocol'; +import { PluginServer, PluginDeployer, PluginStorageKind, PluginType, PluginDeployOptions } from '../../common/plugin-protocol'; import { KeysToAnyValues, KeysToKeysToAnyValue } from '../../common/types'; @injectable() @@ -30,12 +30,12 @@ export class PluginServerHandler implements PluginServer { @inject(PluginsKeyValueStorage) protected readonly pluginsKeyValueStorage: PluginsKeyValueStorage; - deploy(pluginEntry: string, arg2?: PluginType | CancellationToken): Promise { + deploy(pluginEntry: string, arg2?: PluginType | CancellationToken, options?: PluginDeployOptions): Promise { const type = typeof arg2 === 'number' ? arg2 as PluginType : undefined; - return this.doDeploy(pluginEntry, type); + return this.doDeploy(pluginEntry, type, options); } - protected doDeploy(pluginEntry: string, type: PluginType = PluginType.User): Promise { - return this.pluginDeployer.deploy(pluginEntry, type); + protected doDeploy(pluginEntry: string, type: PluginType = PluginType.User, options?: PluginDeployOptions): Promise { + return this.pluginDeployer.deploy(pluginEntry, type, options); } undeploy(pluginId: string): Promise { diff --git a/packages/vsx-registry/src/browser/vsx-extension.tsx b/packages/vsx-registry/src/browser/vsx-extension.tsx index 1ba11e015f3dd..2770ae2243d83 100644 --- a/packages/vsx-registry/src/browser/vsx-extension.tsx +++ b/packages/vsx-registry/src/browser/vsx-extension.tsx @@ -20,20 +20,21 @@ import URI from '@theia/core/lib/common/uri'; import { TreeElement } from '@theia/core/lib/browser/source-tree'; import { OpenerService, open, OpenerOptions } from '@theia/core/lib/browser/opener-service'; import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin'; -import { PluginServer, DeployedPlugin, PluginType } from '@theia/plugin-ext/lib/common/plugin-protocol'; +import { PluginServer, DeployedPlugin, PluginType, PluginDeployOptions } from '@theia/plugin-ext/lib/common/plugin-protocol'; import { VSXExtensionUri } from '../common/vsx-extension-uri'; import { ProgressService } from '@theia/core/lib/common/progress-service'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { VSXEnvironment } from '../common/vsx-environment'; import { VSXExtensionsSearchModel } from './vsx-extensions-search-model'; import { VSXExtensionNamespaceAccess, VSXUser } from '../common/vsx-registry-types'; -import { MenuPath } from '@theia/core/lib/common'; +import { CommandRegistry, MenuPath } from '@theia/core/lib/common'; import { ContextMenuRenderer } from '@theia/core/lib/browser'; export const EXTENSIONS_CONTEXT_MENU: MenuPath = ['extensions_context_menu']; export namespace VSXExtensionsContextMenu { - export const COPY = [...EXTENSIONS_CONTEXT_MENU, '1_copy']; + export const INSTALL = [...EXTENSIONS_CONTEXT_MENU, '1_install']; + export const COPY = [...EXTENSIONS_CONTEXT_MENU, '2_copy']; } @injectable() @@ -102,8 +103,8 @@ export class VSXExtension implements VSXExtensionData, TreeElement { @inject(ProgressService) protected readonly progressService: ProgressService; - @inject(ContextMenuRenderer) - protected readonly contextMenuRenderer: ContextMenuRenderer; + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; @inject(VSXEnvironment) readonly environment: VSXEnvironment; @@ -111,6 +112,9 @@ export class VSXExtension implements VSXExtensionData, TreeElement { @inject(VSXExtensionsSearchModel) readonly search: VSXExtensionsSearchModel; + @inject(ContextMenuRenderer) + protected readonly contextMenuRenderer: ContextMenuRenderer; + protected readonly data: Partial = {}; get uri(): URI { @@ -248,11 +252,11 @@ export class VSXExtension implements VSXExtensionData, TreeElement { return !!this._busy; } - async install(): Promise { + async install(options?: PluginDeployOptions): Promise { this._busy++; try { await this.progressService.withProgress(`"Installing '${this.id}' extension...`, 'extensions', () => - this.pluginServer.deploy(this.uri.toString()) + this.pluginServer.deploy(this.uri.toString(), undefined, options) ); } finally { this._busy--; diff --git a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts index 2bc71746dca8b..8843144d8795c 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts @@ -24,12 +24,23 @@ import { ColorContribution } from '@theia/core/lib/browser/color-application-con import { ColorRegistry, Color } from '@theia/core/lib/browser/color-registry'; import { TabBarToolbarContribution, TabBarToolbarItem, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser/frontend-application'; -import { MenuModelRegistry, MessageService, Mutable } from '@theia/core/lib/common'; +import { MessageService, Mutable } from '@theia/core/lib/common'; import { FileDialogService, OpenFileDialogProps } from '@theia/filesystem/lib/browser'; import { LabelProvider } from '@theia/core/lib/browser'; import { VscodeCommands } from '@theia/plugin-ext-vscode/lib/browser/plugin-vscode-commands-contribution'; -import { VSXExtensionsContextMenu, VSXExtension } from './vsx-extension'; +import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu'; +import { VSXExtension, VSXExtensionsContextMenu } from './vsx-extension'; +import { QuickPickItem, QuickPickService } from '@theia/core/lib/common/quick-pick-service'; +import { VSXRegistryAPI } from '../common/vsx-registry-api'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as moment from 'moment'; +import { PluginServer } from '@theia/plugin-ext/lib/common'; import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { VSXExtensionRaw } from '../common/vsx-registry-types'; + +interface QuickPickVersionItem { + version: string; +} export namespace VSXExtensionsCommands { @@ -47,6 +58,9 @@ export namespace VSXExtensionsCommands { label: 'Install from VSIX...', dialogLabel: 'Install from VSIX' }; + export const INSTALL_ANOTHER_VERSION: Command = { + id: 'vsxExtensions.installAnotherVersion' + }; export const COPY: Command = { id: 'vsxExtensions.copy' }; @@ -57,7 +71,7 @@ export namespace VSXExtensionsCommands { @injectable() export class VSXExtensionsContribution extends AbstractViewContribution - implements ColorContribution, FrontendApplicationContribution, TabBarToolbarContribution { + implements ColorContribution, FrontendApplicationContribution, TabBarToolbarContribution, MenuContribution { @inject(VSXExtensionsModel) protected readonly model: VSXExtensionsModel; @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; @@ -65,6 +79,9 @@ export class VSXExtensionsContribution extends AbstractViewContribution this.installFromVSIX() }); + commands.registerCommand({ id: VSXExtensionsCommands.INSTALL_ANOTHER_VERSION.id }, { + execute: async (extension: VSXExtension) => this.installAnotherVersion(extension), + isEnabled: (extension: VSXExtension) => !extension.builtin && !!extension.downloadUrl + }); + commands.registerCommand(VSXExtensionsCommands.COPY, { execute: (extension: VSXExtension) => this.copy(extension) }); @@ -145,6 +167,11 @@ export class VSXExtensionsContribution extends AbstractViewContribution { const props: OpenFileDialogProps = { @@ -215,6 +242,42 @@ export class VSXExtensionsContribution extends AbstractViewContribution { + const extensionId = extension.id; + const currentVersion = extension.version; + const extensions = await this.vsxRegistryAPI.getAllVersions(extensionId); + const latestCompatible = await this.vsxRegistryAPI.getLatestCompatibleExtensionVersion(extensionId); + let compatibleExtensions: VSXExtensionRaw[] = []; + if (latestCompatible) { + compatibleExtensions = extensions.slice(extensions.findIndex(ext => ext.version === latestCompatible.version)); + } + const items: QuickPickItem[] = []; + compatibleExtensions.forEach(ext => { + let publishedDate = moment(ext.timestamp).fromNow(); + if (currentVersion === ext.version) { + publishedDate += ' (Current)'; + } + items.push({ + label: ext.version, + value: { version: ext.version }, + description: publishedDate + }); + }); + const selectedVersion = await this.quickPickService.show(items, { placeholder: 'Select Version to Install', runIfSingle: false }); + if (selectedVersion && selectedVersion.version !== currentVersion) { + const selectedExtension = this.model.getExtension(extensionId); + if (selectedExtension) { + selectedExtension.uninstall(); + selectedExtension.install({ version: selectedVersion.version }); + } + } + } + protected async copy(extension: VSXExtension): Promise { this.clipboardService.writeText(await extension.serialize()); } diff --git a/packages/vsx-registry/src/browser/vsx-extensions-model.ts b/packages/vsx-registry/src/browser/vsx-extensions-model.ts index bbaed3b7935a5..fd7167362c365 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-model.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-model.ts @@ -21,7 +21,7 @@ import * as sanitize from 'sanitize-html'; import { Emitter } from '@theia/core/lib/common/event'; import { CancellationToken, CancellationTokenSource } from '@theia/core/lib/common/cancellation'; import { VSXRegistryAPI, VSXResponseError } from '../common/vsx-registry-api'; -import { VSXSearchParam } from '../common/vsx-registry-types'; +import { VSXExtensionRaw, VSXSearchParam } from '../common/vsx-registry-types'; import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin'; import { VSXExtension, VSXExtensionFactory } from './vsx-extension'; import { ProgressService } from '@theia/core/lib/common/progress-service'; @@ -164,16 +164,21 @@ export class VSXExtensionsModel { const currInstalled = new Set(); const refreshing = []; for (const plugin of plugins) { + const version = plugin.model.version; if (plugin.model.engine.type === 'vscode') { const id = plugin.model.id; this._installed.delete(id); const extension = this.setExtension(id); currInstalled.add(extension.id); - refreshing.push(this.refresh(id)); + refreshing.push(this.refresh(id, version)); } } for (const id of this._installed) { - refreshing.push(this.refresh(id)); + const extension = this.getExtension(id); + if (!extension) { + return; + } + refreshing.push(this.refresh(id, extension.version)); } Promise.all(refreshing); const installed = new Set([...prevInstalled, ...currInstalled]); @@ -217,13 +222,18 @@ export class VSXExtensionsModel { }); } - protected async refresh(id: string): Promise { + protected async refresh(id: string, version?: string): Promise { try { let extension = this.getExtension(id); if (!this.shouldRefresh(extension)) { return extension; } - const data = await this.api.getLatestCompatibleExtensionVersion(id); + let data: VSXExtensionRaw | undefined; + if (version) { + data = await this.api.getExtension(id, { extensionVersion: version }); + } else { + data = await this.api.getLatestCompatibleExtensionVersion(id); + } if (!data) { return; } @@ -285,5 +295,4 @@ export class VSXExtensionsModel { } return 0; } - } diff --git a/packages/vsx-registry/src/common/vsx-registry-api.ts b/packages/vsx-registry/src/common/vsx-registry-api.ts index 7158d5e5df880..26288cf63633e 100644 --- a/packages/vsx-registry/src/common/vsx-registry-api.ts +++ b/packages/vsx-registry/src/common/vsx-registry-api.ts @@ -88,10 +88,11 @@ export class VSXRegistryAPI { return searchUri; } - async getExtension(id: string): Promise { + async getExtension(id: string, queryParam?: QueryParam): Promise { const apiUri = await this.environment.getRegistryApiUri(); const param: QueryParam = { - extensionId: id + ...queryParam, + extensionId: id, }; const result = await this.postJson(apiUri.resolve('-/query').toString(), param); if (result.extensions && result.extensions.length > 0) { diff --git a/packages/vsx-registry/src/node/vsx-extension-resolver.ts b/packages/vsx-registry/src/node/vsx-extension-resolver.ts index 0152081892159..2fc6ee33552a7 100644 --- a/packages/vsx-registry/src/node/vsx-extension-resolver.ts +++ b/packages/vsx-registry/src/node/vsx-extension-resolver.ts @@ -21,9 +21,10 @@ import { v4 as uuidv4 } from 'uuid'; import * as requestretry from 'requestretry'; import { injectable, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; -import { PluginDeployerResolver, PluginDeployerResolverContext } from '@theia/plugin-ext/lib/common/plugin-protocol'; +import { PluginDeployerResolver, PluginDeployerResolverContext, PluginDeployOptions } from '@theia/plugin-ext/lib/common/plugin-protocol'; import { VSXExtensionUri } from '../common/vsx-extension-uri'; import { VSXRegistryAPI } from '../common/vsx-registry-api'; +import { VSXExtensionRaw } from '../common/vsx-registry-types'; @injectable() export class VSXExtensionResolver implements PluginDeployerResolver { @@ -43,23 +44,38 @@ export class VSXExtensionResolver implements PluginDeployerResolver { return !!VSXExtensionUri.toId(new URI(pluginId)); } - async resolve(context: PluginDeployerResolverContext): Promise { + async resolve(context: PluginDeployerResolverContext, options?: PluginDeployOptions): Promise { const id = VSXExtensionUri.toId(new URI(context.getOriginId())); if (!id) { return; } - console.log(`[${id}]: trying to resolve latest version...`); - const extension = await this.api.getLatestCompatibleExtensionVersion(id); - if (!extension) { - return; - } - if (extension.error) { - throw new Error(extension.error); + + let extensionVersion: string | undefined; + let extension: VSXExtensionRaw | undefined; + if (options) { + extensionVersion = options.version; + console.log(`[${id}]: trying to resolve version ${extensionVersion}...`); + extension = await this.api.getExtension(id, { extensionVersion: extensionVersion }); + if (!extension) { + return; + } + if (extension.error) { + throw new Error(extension.error); + } + } else { + console.log(`[${id}]: trying to resolve latest version...`); + extension = await this.api.getLatestCompatibleExtensionVersion(id); + if (!extension) { + return; + } + if (extension.error) { + throw new Error(extension.error); + } + extensionVersion = extension.version; } - const resolvedId = id + '-' + extension.version; + const resolvedId = id + '-' + extensionVersion; const downloadUrl = extension.files.download; console.log(`[${id}]: resolved to '${resolvedId}'`); - const extensionPath = path.resolve(this.downloadPath, path.basename(downloadUrl)); console.log(`[${resolvedId}]: trying to download from "${downloadUrl}"...`); if (!await this.download(downloadUrl, extensionPath)) {