From d6270c1e0a03dc08b7663ac2a0129d785f5ad8da Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Sat, 6 Feb 2021 00:14:04 +0100 Subject: [PATCH] Enable extensions.json Signed-off-by: Colin Grant --- .../browser/preferences/preference-service.ts | 4 +- .../browser/folders-preferences-provider.ts | 70 +++++++------ .../user-configs-preference-provider.ts | 24 +++-- .../workspace-file-preference-provider.ts | 14 ++- packages/vsx-registry/compile.tsconfig.json | 6 ++ packages/vsx-registry/package.json | 2 + .../preference-provider-overrides.ts | 99 +++++++++++++++++++ .../recommended-extensions-json-schema.ts | 72 ++++++++++++++ ...nded-extensions-preference-contribution.ts | 67 +++++++++++++ .../browser/vsx-extensions-contribution.ts | 49 ++++++++- .../src/browser/vsx-extensions-model.ts | 70 ++++++++++++- .../browser/vsx-extensions-search-model.ts | 17 ++++ .../src/browser/vsx-extensions-source.ts | 9 ++ .../browser/vsx-extensions-view-container.ts | 55 +++++++---- .../src/browser/vsx-extensions-widget.ts | 28 +++--- .../browser/vsx-registry-frontend-module.ts | 13 ++- 16 files changed, 523 insertions(+), 76 deletions(-) create mode 100644 packages/vsx-registry/src/browser/recommended-extensions/preference-provider-overrides.ts create mode 100644 packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-json-schema.ts create mode 100644 packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-preference-contribution.ts diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index bdba3241b13f2..4df391c6fdba3 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -265,6 +265,8 @@ export interface PreferenceInspection { value: T | undefined; } +export type PreferenceInspectionScope = keyof Omit, 'preferenceName'>; + /** * We cannot load providers directly in the case if they depend on `PreferenceService` somehow. * It allows to load them lazily after DI is configured. @@ -430,7 +432,7 @@ export class PreferenceServiceImpl implements PreferenceService { if (provider && await provider.setPreference(preferenceName, value, resourceUri)) { return; } - throw new Error(`Unable to write to ${PreferenceScope.getScopeNames(resolvedScope)[0]} Settings.`); + throw new Error(`Unable to write to ${PreferenceScope[resolvedScope]} Settings.`); } getBoolean(preferenceName: string): boolean | undefined; diff --git a/packages/preferences/src/browser/folders-preferences-provider.ts b/packages/preferences/src/browser/folders-preferences-provider.ts index 1fd1e48be9d1b..c27e10cf3ee3a 100644 --- a/packages/preferences/src/browser/folders-preferences-provider.ts +++ b/packages/preferences/src/browser/folders-preferences-provider.ts @@ -133,45 +133,59 @@ export class FoldersPreferencesProvider extends PreferenceProvider { } async setPreference(preferenceName: string, value: any, resourceUri?: string): Promise { - const sectionName = preferenceName.split('.', 1)[0]; - const configName = this.configurations.isSectionName(sectionName) ? sectionName : this.configurations.getConfigName(); + const firstPathFragment = preferenceName.split('.', 1)[0]; + const defaultConfigName = this.configurations.getConfigName(); + const configName = this.configurations.isSectionName(firstPathFragment) ? firstPathFragment : defaultConfigName; const providers = this.getFolderProviders(resourceUri); let configPath: string | undefined; - - const iterator: (() => FolderPreferenceProvider | undefined)[] = []; - for (const provider of providers) { - if (configPath === undefined) { - const configUri = provider.getConfigUri(resourceUri); - if (configUri) { - configPath = this.configurations.getPath(configUri); - } + const candidates = providers.filter(provider => { + // Attempt to figure out the settings folder (.vscode or .theia) we're interested in. + const containingConfigUri = provider.getConfigUri(resourceUri); + if (configPath === undefined && containingConfigUri) { + configPath = this.configurations.getPath(containingConfigUri); } - if (this.configurations.getName(provider.getConfigUri()) === configName) { - iterator.push(() => { - if (provider.getConfigUri(resourceUri)) { - return provider; - } - iterator.push(() => { - if (this.configurations.getPath(provider.getConfigUri()) === configPath) { - return provider; - } - iterator.push(() => provider); - }); - }); + const providerName = this.configurations.getName(containingConfigUri ?? provider.getConfigUri()); + return providerName === configName || providerName === defaultConfigName; + }); + + const configNameAndPathMatches = []; + const configNameOnlyMatches = []; + const configUriMatches = []; + const otherMatches = []; + + for (const candidate of candidates) { + const domainMatches = candidate.getConfigUri(resourceUri); + const configUri = domainMatches ?? candidate.getConfigUri(); + const nameMatches = this.configurations.getName(configUri) === configName; + const pathMatches = this.configurations.getPath(configUri) === configPath; + + // Perfect match, run immediately in case we can bail out early. + if (nameMatches && domainMatches) { + if (await candidate.setPreference(preferenceName, value, resourceUri)) { + return true; + } + } else if (nameMatches && pathMatches) { // Right file in the right folder. + configNameAndPathMatches.push(candidate); + } else if (nameMatches) { // Right file. + configNameOnlyMatches.push(candidate); + } else if (domainMatches) { // Currently valid and governs target URI + configUriMatches.push(candidate); + } else { + otherMatches.push(candidate); } } - let next = iterator.shift(); - while (next) { - const provider = next(); - if (provider) { - if (await provider.setPreference(preferenceName, value, resourceUri)) { + const candidateSets = [configNameAndPathMatches, configNameOnlyMatches, configUriMatches, otherMatches]; + + for (const candidateSet of candidateSets) { + for (const candidate of candidateSet) { + if (await candidate.setPreference(preferenceName, value, resourceUri)) { return true; } } - next = iterator.shift(); } + return false; } diff --git a/packages/preferences/src/browser/user-configs-preference-provider.ts b/packages/preferences/src/browser/user-configs-preference-provider.ts index 616882c3dacd9..19891acae6e8e 100644 --- a/packages/preferences/src/browser/user-configs-preference-provider.ts +++ b/packages/preferences/src/browser/user-configs-preference-provider.ts @@ -92,14 +92,24 @@ export class UserConfigsPreferenceProvider extends PreferenceProvider { async setPreference(preferenceName: string, value: any, resourceUri?: string): Promise { const sectionName = preferenceName.split('.', 1)[0]; - const configName = this.configurations.isSectionName(sectionName) ? sectionName : this.configurations.getConfigName(); - - const providers = this.providers.values(); - - for (const provider of providers) { - if (this.configurations.getName(provider.getConfigUri()) === configName) { - return provider.setPreference(preferenceName, value, resourceUri); + const defaultConfigName = this.configurations.getConfigName(); + const configName = this.configurations.isSectionName(sectionName) ? sectionName : defaultConfigName; + + const setWithConfigName = async (name: string): Promise => { + for (const provider of this.providers.values()) { + if (this.configurations.getName(provider.getConfigUri()) === name) { + if (await provider.setPreference(preferenceName, value, resourceUri)) { + return true; + } + } } + return false; + }; + + if (await setWithConfigName(configName)) { // Try in the section we believe it belongs in. + return true; + } else if (configName !== defaultConfigName) { // Fall back to `settings.json` if that fails. + return setWithConfigName(defaultConfigName); } return false; } diff --git a/packages/preferences/src/browser/workspace-file-preference-provider.ts b/packages/preferences/src/browser/workspace-file-preference-provider.ts index c731af255d5a9..4c2f418f18602 100644 --- a/packages/preferences/src/browser/workspace-file-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-file-preference-provider.ts @@ -65,13 +65,13 @@ export class WorkspaceFilePreferenceProvider extends AbstractResourcePreferenceP } protected getPath(preferenceName: string): string[] { - const firstSegment = preferenceName.split('.')[0]; - if (firstSegment && this.configurations.isSectionName(firstSegment)) { + const firstSegment = preferenceName.split('.', 1)[0]; + const remainder = preferenceName.slice(firstSegment.length + 1); + if (this.belongsInSection(firstSegment, remainder)) { // Default to writing sections outside the "settings" object. const path = [firstSegment]; - const pathRemainder = preferenceName.slice(firstSegment.length + 1); - if (pathRemainder) { - path.push(pathRemainder); + if (remainder) { + path.push(remainder); } // If the user has already written this section inside the "settings" object, modify it there. if (this.sectionsInsideSettings.has(firstSegment)) { @@ -82,6 +82,10 @@ export class WorkspaceFilePreferenceProvider extends AbstractResourcePreferenceP return ['settings', preferenceName]; } + protected belongsInSection(firstSegment: string, remainder: string): boolean { + return this.configurations.isSectionName(firstSegment); + } + protected getScope(): PreferenceScope { return PreferenceScope.Workspace; } diff --git a/packages/vsx-registry/compile.tsconfig.json b/packages/vsx-registry/compile.tsconfig.json index 355e20446e63d..583c52cd6cbf6 100644 --- a/packages/vsx-registry/compile.tsconfig.json +++ b/packages/vsx-registry/compile.tsconfig.json @@ -20,6 +20,12 @@ }, { "path": "../filesystem/compile.tsconfig.json" + }, + { + "path": "../preferences/compile.tsconfig.json" + }, + { + "path": "../workspace/compile.tsconfig.json" } ] } diff --git a/packages/vsx-registry/package.json b/packages/vsx-registry/package.json index c89a5603d539a..b1c5bba58d0e7 100644 --- a/packages/vsx-registry/package.json +++ b/packages/vsx-registry/package.json @@ -7,6 +7,8 @@ "@theia/filesystem": "1.14.0", "@theia/plugin-ext": "1.14.0", "@theia/plugin-ext-vscode": "1.14.0", + "@theia/preferences": "1.14.0", + "@theia/workspace": "1.14.0", "@types/bent": "^7.0.1", "@types/dompurify": "^2.0.2", "@types/sanitize-html": "^2.3.1", diff --git a/packages/vsx-registry/src/browser/recommended-extensions/preference-provider-overrides.ts b/packages/vsx-registry/src/browser/recommended-extensions/preference-provider-overrides.ts new file mode 100644 index 0000000000000..0969cd9d40f23 --- /dev/null +++ b/packages/vsx-registry/src/browser/recommended-extensions/preference-provider-overrides.ts @@ -0,0 +1,99 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + FolderPreferenceProvider, + FolderPreferenceProviderFactory, + FolderPreferenceProviderFolder, + UserPreferenceProvider, + UserPreferenceProviderFactory +} from '@theia/preferences/lib/browser'; +import { Container, injectable, interfaces } from '@theia/core/shared/inversify'; +import { extensionsConfigurationSchema } from './recommended-extensions-json-schema'; +import { + WorkspaceFilePreferenceProvider, + WorkspaceFilePreferenceProviderFactory, + WorkspaceFilePreferenceProviderOptions +} from '@theia/preferences/lib/browser/workspace-file-preference-provider'; +import { bindFactory } from '@theia/preferences/lib/browser/preference-bindings'; +import { SectionPreferenceProviderSection, SectionPreferenceProviderUri } from '@theia/preferences/lib/browser/section-preference-provider'; + +/** + * The overrides in this file are required because the base preference providers assume that a + * section name (extensions) will not be used as a prefix (extensions.ignoreRecommendations). + */ + +@injectable() +export class FolderPreferenceProviderWithExtensions extends FolderPreferenceProvider { + protected getPath(preferenceName: string): string[] | undefined { + const path = super.getPath(preferenceName); + if (this.section !== 'extensions' || !path?.length) { + return path; + } + const isExtensionsField = path[0] in extensionsConfigurationSchema.properties!; + if (isExtensionsField) { + return path; + } + return undefined; + } +} + +@injectable() +export class UserPreferenceProviderWithExtensions extends UserPreferenceProvider { + protected getPath(preferenceName: string): string[] | undefined { + const path = super.getPath(preferenceName); + if (this.section !== 'extensions' || !path?.length) { + return path; + } + const isExtensionsField = path[0] in extensionsConfigurationSchema.properties!; + if (isExtensionsField) { + return path; + } + return undefined; + } +} + +@injectable() +export class WorkspaceFilePreferenceProviderWithExtensions extends WorkspaceFilePreferenceProvider { + protected belongsInSection(firstSegment: string, remainder: string): boolean { + if (firstSegment === 'extensions') { + return remainder in extensionsConfigurationSchema.properties!; + } + return this.configurations.isSectionName(firstSegment); + } +} + +export function bindPreferenceProviderOverrides(bind: interfaces.Bind, unbind: interfaces.Unbind): void { + unbind(UserPreferenceProviderFactory); + unbind(FolderPreferenceProviderFactory); + unbind(WorkspaceFilePreferenceProviderFactory); + bindFactory(bind, UserPreferenceProviderFactory, UserPreferenceProviderWithExtensions, SectionPreferenceProviderUri, SectionPreferenceProviderSection); + bindFactory( + bind, + FolderPreferenceProviderFactory, + FolderPreferenceProviderWithExtensions, + SectionPreferenceProviderUri, + SectionPreferenceProviderSection, + FolderPreferenceProviderFolder, + ); + bind(WorkspaceFilePreferenceProviderFactory).toFactory(ctx => (options: WorkspaceFilePreferenceProviderOptions) => { + const child = new Container({ defaultScope: 'Singleton' }); + child.parent = ctx.container; + child.bind(WorkspaceFilePreferenceProvider).to(WorkspaceFilePreferenceProviderWithExtensions); + child.bind(WorkspaceFilePreferenceProviderOptions).toConstantValue(options); + return child.get(WorkspaceFilePreferenceProvider); + }); +} diff --git a/packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-json-schema.ts b/packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-json-schema.ts new file mode 100644 index 0000000000000..90e220b718760 --- /dev/null +++ b/packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-json-schema.ts @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { InMemoryResources } from '@theia/core'; +import { JsonSchemaContribution, JsonSchemaRegisterContext } from '@theia/core/lib/browser/json-schema-store'; +import { IJSONSchema } from '@theia/core/lib/common/json-schema'; +import URI from '@theia/core/lib/common/uri'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; + +export const extensionsSchemaID = 'vscode://schemas/extensions'; +export const extensionsConfigurationSchema: IJSONSchema = { + $id: extensionsSchemaID, + default: { recommendations: [] }, + type: 'object', + + properties: { + recommendations: { + title: 'A list of extensions recommended for users of this workspace. Should use the form "."', + type: 'array', + items: { + type: 'string', + pattern: '^\\w[\\w-]+\\.\\w[\\w-]+$', + patternErrorMessage: "Expected format '${publisher}.${name}'. Example: 'eclipse.theia'." + }, + default: [], + }, + unwantedRecommendations: { + title: 'A list of extensions recommended by default that should not be recommended to users of this workspace. Should use the form "."', + type: 'array', + items: { + type: 'string', + pattern: '^\\w[\\w-]+\\.\\w[\\w-]+$', + patternErrorMessage: "Expected format '${publisher}.${name}'. Example: 'eclipse.theia'." + }, + default: [], + } + } +}; + +@injectable() +export class ExtensionSchemaContribution implements JsonSchemaContribution { + protected readonly uri = new URI(extensionsSchemaID); + @inject(InMemoryResources) protected readonly inmemoryResources: InMemoryResources; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + + @postConstruct() + protected init(): void { + this.inmemoryResources.add(this.uri, JSON.stringify(extensionsConfigurationSchema)); + } + + registerSchemas(context: JsonSchemaRegisterContext): void { + context.registerSchema({ + fileMatch: ['extensions.json'], + url: this.uri.toString(), + }); + this.workspaceService.updateSchema('extensions', { $ref: this.uri.toString() }); + } +} diff --git a/packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-preference-contribution.ts b/packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-preference-contribution.ts new file mode 100644 index 0000000000000..489bb0dfcaa2d --- /dev/null +++ b/packages/vsx-registry/src/browser/recommended-extensions/recommended-extensions-preference-contribution.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (C) 2021 Ericsson and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { createPreferenceProxy, PreferenceContribution, PreferenceSchema, PreferenceScope, PreferenceService } from '@theia/core/lib/browser'; +import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store'; +import { PreferenceConfiguration } from '@theia/core/lib/browser/preferences/preference-configurations'; +import { interfaces } from '@theia/core/shared/inversify'; +import { ExtensionSchemaContribution, extensionsSchemaID } from './recommended-extensions-json-schema'; + +export interface RecommendedExtensions { + recommendations?: string[]; + unwantedRecommendations?: string[]; +} + +export const recommendedExtensionsPreferencesSchema: PreferenceSchema = { + type: 'object', + scope: PreferenceScope.Folder, + properties: { + extensions: { + $ref: extensionsSchemaID, + description: 'A list of the names of extensions recommended for use in this workspace.', + defaultValue: { recommendations: [] }, + }, + }, +}; + +export const IGNORE_RECOMMENDATIONS_ID = 'extensions.ignoreRecommendations'; + +export const recommendedExtensionNotificationPreferencesSchema: PreferenceSchema = { + type: 'object', + scope: PreferenceScope.Folder, + properties: { + [IGNORE_RECOMMENDATIONS_ID]: { + description: 'Controls whether notifications are shown for extension recommendations.', + default: false, + type: 'boolean' + } + } +}; + +export const ExtensionNotificationPreferences = Symbol('ExtensionNotificationPreferences'); + +export function bindExtensionPreferences(bind: interfaces.Bind): void { + bind(ExtensionSchemaContribution).toSelf().inSingletonScope(); + bind(JsonSchemaContribution).toService(ExtensionSchemaContribution); + bind(PreferenceContribution).toConstantValue({ schema: recommendedExtensionsPreferencesSchema }); + bind(PreferenceConfiguration).toConstantValue({ name: 'extensions' }); + + bind(ExtensionNotificationPreferences).toDynamicValue(({ container }) => { + const preferenceService = container.get(PreferenceService); + return createPreferenceProxy(preferenceService, recommendedExtensionNotificationPreferencesSchema); + }).inSingletonScope(); + bind(PreferenceContribution).toConstantValue({ schema: recommendedExtensionNotificationPreferencesSchema }); +} diff --git a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts index 2bc71746dca8b..6defaeb2c6f7d 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-contribution.ts @@ -14,7 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable, inject } from '@theia/core/shared/inversify'; +import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; +import debounce = require('@theia/core/shared/lodash.debounce'); import { Command, CommandRegistry } from '@theia/core/lib/common/command'; import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; import { VSXExtensionsViewContainer } from './vsx-extensions-view-container'; @@ -26,10 +27,12 @@ import { TabBarToolbarContribution, TabBarToolbarItem, TabBarToolbarRegistry } f import { FrontendApplicationContribution, FrontendApplication } from '@theia/core/lib/browser/frontend-application'; import { MenuModelRegistry, MessageService, Mutable } from '@theia/core/lib/common'; import { FileDialogService, OpenFileDialogProps } from '@theia/filesystem/lib/browser'; -import { LabelProvider } from '@theia/core/lib/browser'; +import { LabelProvider, PreferenceService } 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 { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { RECOMMENDED_QUERY } from './vsx-extensions-search-model'; +import { IGNORE_RECOMMENDATIONS_ID } from './recommended-extensions/recommended-extensions-preference-contribution'; export namespace VSXExtensionsCommands { @@ -53,6 +56,11 @@ export namespace VSXExtensionsCommands { export const COPY_EXTENSION_ID: Command = { id: 'vsxExtensions.copyExtensionId' }; + export const SHOW_RECOMMENDATIONS: Command = { + id: 'vsxExtension.showRecommendations', + label: 'Show Recommended Extensions', + category: EXTENSIONS_CATEGORY, + }; } @injectable() @@ -66,6 +74,7 @@ export class VSXExtensionsContribution extends AbstractViewContribution { + this.showRecommendedToast(); + oneShotDisposable.dispose(); + }, 5000, { trailing: true })); + } + async initializeLayout(app: FrontendApplication): Promise { await this.openView({ activate: false }); } @@ -103,6 +120,10 @@ export class VSXExtensionsContribution extends AbstractViewContribution this.copyExtensionId(extension) }); + + commands.registerCommand(VSXExtensionsCommands.SHOW_RECOMMENDATIONS, { + execute: () => this.showRecommendedExtensions() + }); } registerToolbarItems(registry: TabBarToolbarRegistry): void { @@ -222,4 +243,28 @@ export class VSXExtensionsContribution extends AbstractViewContribution { + if (!this.preferenceService.get(IGNORE_RECOMMENDATIONS_ID, false)) { + const recommended = new Set([...this.model.recommended]); + for (const installed of this.model.installed) { + recommended.delete(installed); + } + if (recommended.size) { + const userResponse = await this.messageService.info('Would you like to install the recommended extensions?', 'Install', 'Show Recommended'); + if (userResponse === 'Install') { + for (const recommendation of recommended) { + this.model.getExtension(recommendation)?.install(); + } + } else if (userResponse === 'Show Recommended') { + await this.showRecommendedExtensions(); + } + } + } + } + + protected async showRecommendedExtensions(): Promise { + await this.openView({ activate: true }); + this.model.search.query = RECOMMENDED_QUERY; + } } diff --git a/packages/vsx-registry/src/browser/vsx-extensions-model.ts b/packages/vsx-registry/src/browser/vsx-extensions-model.ts index a58229501a8f9..baa5f1c993900 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-model.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-model.ts @@ -27,6 +27,10 @@ import { VSXExtension, VSXExtensionFactory } from './vsx-extension'; import { ProgressService } from '@theia/core/lib/common/progress-service'; import { VSXExtensionsSearchModel } from './vsx-extensions-search-model'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { PreferenceInspectionScope, PreferenceService } from '@theia/core/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { RecommendedExtensions } from './recommended-extensions/recommended-extensions-preference-contribution'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class VSXExtensionsModel { @@ -46,6 +50,12 @@ export class VSXExtensionsModel { @inject(ProgressService) protected readonly progressService: ProgressService; + @inject(PreferenceService) + protected readonly preferences: PreferenceService; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + @inject(VSXExtensionsSearchModel) readonly search: VSXExtensionsSearchModel; @@ -55,7 +65,8 @@ export class VSXExtensionsModel { protected async init(): Promise { await Promise.all([ this.initInstalled(), - this.initSearchResult() + this.initSearchResult(), + this.initRecommended(), ]); this.initialized.resolve(); } @@ -79,6 +90,20 @@ export class VSXExtensionsModel { } } + protected async initRecommended(): Promise { + this.preferences.onPreferenceChanged(change => { + if (change.preferenceName === 'extensions') { + this.updateRecommended(); + } + }); + await this.preferences.ready; + try { + await this.updateRecommended(); + } catch (e) { + console.error(e); + } + } + /** * single source of all extensions */ @@ -89,11 +114,20 @@ export class VSXExtensionsModel { return this._installed.values(); } + isInstalled(id: string): boolean { + return this._installed.has(id); + } + protected _searchResult = new Set(); get searchResult(): IterableIterator { return this._searchResult.values(); } + protected _recommended = new Set(); + get recommended(): IterableIterator { + return this._recommended.values(); + } + getExtension(id: string): VSXExtension | undefined { return this.extensions.get(id); } @@ -182,6 +216,40 @@ export class VSXExtensionsModel { }); } + protected updateRecommended(): Promise> { + return this.doChange>(async () => { + const allRecommendations = new Set(); + const allUnwantedRecommendations = new Set(); + + const updateRecommendationsForScope = (scope: PreferenceInspectionScope, root?: URI) => { + const { recommendations, unwantedRecommendations } = this.getRecommendationsForScope(scope, root); + recommendations.forEach(recommendation => allRecommendations.add(recommendation)); + unwantedRecommendations.forEach(unwantedRecommendation => allUnwantedRecommendations.add(unwantedRecommendation)); + }; + + updateRecommendationsForScope('defaultValue'); // In case there are application-default recommendations. + const roots = await this.workspaceService.roots; + for (const root of roots) { + updateRecommendationsForScope('workspaceFolderValue', root.resource); + } + if (this.workspaceService.saved) { + updateRecommendationsForScope('workspaceValue'); + } + const recommendedSorted = new Set(Array.from(allRecommendations).sort((a, b) => this.compareExtensions(a, b)).values()); + allUnwantedRecommendations.forEach(unwantedRecommendation => recommendedSorted.delete(unwantedRecommendation)); + this._recommended = recommendedSorted; + return Promise.all(Array.from(recommendedSorted, plugin => this.refresh(plugin))); + }); + } + + protected getRecommendationsForScope(scope: PreferenceInspectionScope, root?: URI): Required { + const configuredValue = this.preferences.inspect('extensions', root?.toString())?.[scope]; + return { + recommendations: configuredValue?.recommendations ?? [], + unwantedRecommendations: configuredValue?.unwantedRecommendations ?? [], + }; + } + resolve(id: string): Promise { return this.doChange(async () => { await this.initialized.promise; diff --git a/packages/vsx-registry/src/browser/vsx-extensions-search-model.ts b/packages/vsx-registry/src/browser/vsx-extensions-search-model.ts index 38cea4e691c53..741accdf02e50 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-search-model.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-search-model.ts @@ -17,11 +17,23 @@ import { injectable } from '@theia/core/shared/inversify'; import { Emitter } from '@theia/core/lib/common/event'; +export enum VSXSearchMode { + Initial, + None, + Search, + Recommended, +} + +export const RECOMMENDED_QUERY = '@recommended'; + @injectable() export class VSXExtensionsSearchModel { protected readonly onDidChangeQueryEmitter = new Emitter(); readonly onDidChangeQuery = this.onDidChangeQueryEmitter.event; + protected readonly specialQueries = new Map([ + [RECOMMENDED_QUERY, VSXSearchMode.Recommended] + ]); protected _query = ''; set query(query: string) { @@ -35,4 +47,9 @@ export class VSXExtensionsSearchModel { return this._query; } + getModeForQuery(): VSXSearchMode { + return this.query + ? this.specialQueries.get(this.query) ?? VSXSearchMode.Search + : VSXSearchMode.None; + } } diff --git a/packages/vsx-registry/src/browser/vsx-extensions-source.ts b/packages/vsx-registry/src/browser/vsx-extensions-source.ts index 4dc3aa956d657..2bcbd296a16fe 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-source.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-source.ts @@ -23,6 +23,7 @@ export class VSXExtensionsSourceOptions { static INSTALLED = 'installed'; static BUILT_IN = 'builtin'; static SEARCH_RESULT = 'searchResult'; + static RECOMMENDED = 'recommended'; readonly id: string; } @@ -47,6 +48,11 @@ export class VSXExtensionsSource extends TreeSource { if (!extension) { continue; } + if (this.options.id === VSXExtensionsSourceOptions.RECOMMENDED) { + if (this.model.isInstalled(id)) { + continue; + } + } if (this.options.id === VSXExtensionsSourceOptions.BUILT_IN) { if (extension.builtin) { yield extension; @@ -61,6 +67,9 @@ export class VSXExtensionsSource extends TreeSource { if (this.options.id === VSXExtensionsSourceOptions.SEARCH_RESULT) { return this.model.searchResult; } + if (this.options.id === VSXExtensionsSourceOptions.RECOMMENDED) { + return this.model.recommended; + } return this.model.installed; } diff --git a/packages/vsx-registry/src/browser/vsx-extensions-view-container.ts b/packages/vsx-registry/src/browser/vsx-extensions-view-container.ts index 679466f2621a3..27a3cb89482c0 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-view-container.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-view-container.ts @@ -17,8 +17,10 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { ViewContainer, PanelLayout, ViewContainerPart, Message } from '@theia/core/lib/browser'; import { VSXExtensionsSearchBar } from './vsx-extensions-search-bar'; -import { VSXExtensionsWidget, } from './vsx-extensions-widget'; import { VSXExtensionsModel } from './vsx-extensions-model'; +import { VSXSearchMode } from './vsx-extensions-search-model'; +import { generateExtensionWidgetId } from './vsx-extensions-widget'; +import { VSXExtensionsSourceOptions } from './vsx-extensions-source'; @injectable() export class VSXExtensionsViewContainer extends ViewContainer { @@ -60,15 +62,15 @@ export class VSXExtensionsViewContainer extends ViewContainer { super.configureLayout(layout); } - protected currentMode: VSXExtensionsViewContainer.Mode = VSXExtensionsViewContainer.InitialMode; - protected readonly lastModeState = new Map(); + protected currentMode: VSXSearchMode = VSXSearchMode.Initial; + protected readonly lastModeState = new Map(); protected updateMode(): void { - const currentMode: VSXExtensionsViewContainer.Mode = !this.model.search.query ? VSXExtensionsViewContainer.DefaultMode : VSXExtensionsViewContainer.SearchResultMode; + const currentMode = this.model.search.getModeForQuery(); if (currentMode === this.currentMode) { return; } - if (this.currentMode !== VSXExtensionsViewContainer.InitialMode) { + if (this.currentMode !== VSXSearchMode.Initial) { this.lastModeState.set(this.currentMode, super.doStoreState()); } this.currentMode = currentMode; @@ -80,12 +82,15 @@ export class VSXExtensionsViewContainer extends ViewContainer { this.applyModeToPart(part); } } - if (this.currentMode === VSXExtensionsViewContainer.SearchResultMode) { - const searchPart = this.getParts().find(part => part.wrapped.id === VSXExtensionsWidget.SEARCH_RESULT_ID); - if (searchPart) { - searchPart.collapsed = false; - searchPart.show(); - } + + const specialWidgets = this.getWidgetsForMode(); + if (specialWidgets?.length) { + const widgetChecker = new Set(specialWidgets); + const relevantParts = this.getParts().filter(part => widgetChecker.has(part.wrapped.id)); + relevantParts.forEach(part => { + part.collapsed = false; + part.show(); + }); } } @@ -95,14 +100,32 @@ export class VSXExtensionsViewContainer extends ViewContainer { } protected applyModeToPart(part: ViewContainerPart): void { - const partMode = (part.wrapped.id === VSXExtensionsWidget.SEARCH_RESULT_ID ? VSXExtensionsViewContainer.SearchResultMode : VSXExtensionsViewContainer.DefaultMode); - if (this.currentMode === partMode) { + if (this.shouldShowWidget(part)) { part.show(); } else { part.hide(); } } + protected shouldShowWidget(part: ViewContainerPart): boolean { + const widgetsToShow = this.getWidgetsForMode(); + if (widgetsToShow.length) { + return widgetsToShow.includes(part.wrapped.id); + } + return part.wrapped.id !== generateExtensionWidgetId(VSXExtensionsSourceOptions.SEARCH_RESULT); + } + + protected getWidgetsForMode(): string[] { + switch (this.currentMode) { + case VSXSearchMode.Recommended: + return [generateExtensionWidgetId(VSXExtensionsSourceOptions.RECOMMENDED)]; + case VSXSearchMode.Search: + return [generateExtensionWidgetId(VSXExtensionsSourceOptions.SEARCH_RESULT)]; + default: + return []; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any protected doStoreState(): any { const modes: VSXExtensionsViewContainer.State['modes'] = {}; @@ -119,7 +142,7 @@ export class VSXExtensionsViewContainer extends ViewContainer { protected doRestoreState(state: any): void { // eslint-disable-next-line guard-for-in for (const key in state.modes) { - const mode = Number(key) as VSXExtensionsViewContainer.Mode; + const mode = Number(key) as VSXSearchMode; const modeState = state.modes[mode]; if (modeState) { this.lastModeState.set(mode, modeState); @@ -130,10 +153,6 @@ export class VSXExtensionsViewContainer extends ViewContainer { } export namespace VSXExtensionsViewContainer { - export const InitialMode = 0; - export const DefaultMode = 1; - export const SearchResultMode = 2; - export type Mode = typeof InitialMode | typeof DefaultMode | typeof SearchResultMode; export interface State { query: string; modes: { diff --git a/packages/vsx-registry/src/browser/vsx-extensions-widget.ts b/packages/vsx-registry/src/browser/vsx-extensions-widget.ts index f8fdbdc402a91..46371f9d9fd40 100644 --- a/packages/vsx-registry/src/browser/vsx-extensions-widget.ts +++ b/packages/vsx-registry/src/browser/vsx-extensions-widget.ts @@ -20,15 +20,15 @@ import { VSXExtensionsSource, VSXExtensionsSourceOptions } from './vsx-extension @injectable() export class VSXExtensionsWidgetOptions extends VSXExtensionsSourceOptions { + title?: string; } +export const generateExtensionWidgetId = (widgetId: string): string => VSXExtensionsWidget.ID + ':' + widgetId; + @injectable() export class VSXExtensionsWidget extends SourceTreeWidget { static ID = 'vsx-extensions'; - static INSTALLED_ID = VSXExtensionsWidget.ID + ':' + VSXExtensionsSourceOptions.INSTALLED; - static SEARCH_RESULT_ID = VSXExtensionsWidget.ID + ':' + VSXExtensionsSourceOptions.SEARCH_RESULT; - static BUILT_IN_ID = VSXExtensionsWidget.ID + ':' + VSXExtensionsSourceOptions.BUILT_IN; static createWidget(parent: interfaces.Container, options: VSXExtensionsWidgetOptions): VSXExtensionsWidget { const child = SourceTreeWidget.createContainer(parent, { @@ -54,8 +54,8 @@ export class VSXExtensionsWidget extends SourceTreeWidget { super.init(); this.addClass('theia-vsx-extensions'); - this.id = VSXExtensionsWidget.ID + ':' + this.options.id; - const title = this.computeTitle(); + this.id = generateExtensionWidgetId(this.options.id); + const title = this.options.title ?? this.computeTitle(); this.title.label = title; this.title.caption = title; @@ -64,13 +64,17 @@ export class VSXExtensionsWidget extends SourceTreeWidget { } protected computeTitle(): string { - if (this.id === VSXExtensionsWidget.INSTALLED_ID) { - return 'Installed'; - } - if (this.id === VSXExtensionsWidget.BUILT_IN_ID) { - return 'Built-in'; + switch (this.options.id) { + case VSXExtensionsSourceOptions.INSTALLED: + return 'Installed'; + case VSXExtensionsSourceOptions.BUILT_IN: + return 'Built-in'; + case VSXExtensionsSourceOptions.RECOMMENDED: + return 'Recommended'; + case VSXExtensionsSourceOptions.SEARCH_RESULT: + return 'Open VSX Registry'; + default: + return ''; } - return 'Open VSX Registry'; } - } diff --git a/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts b/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts index ec66b149d62d4..ad787dfeaf4ed 100644 --- a/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts +++ b/packages/vsx-registry/src/browser/vsx-registry-frontend-module.ts @@ -34,8 +34,10 @@ import { VSXEnvironment } from '../common/vsx-environment'; import { VSXExtensionsSearchModel } from './vsx-extensions-search-model'; import { VSXApiVersionProviderImpl } from './vsx-api-version-provider-frontend-impl'; import { VSXApiVersionProvider } from '../common/vsx-api-version-provider'; +import { bindExtensionPreferences } from './recommended-extensions/recommended-extensions-preference-contribution'; +import { bindPreferenceProviderOverrides } from './recommended-extensions/preference-provider-overrides'; -export default new ContainerModule(bind => { +export default new ContainerModule((bind, unbind) => { bind(VSXEnvironment).toSelf().inRequestScope(); bind(VSXRegistryAPI).toSelf().inSingletonScope(); @@ -75,7 +77,12 @@ export default new ContainerModule(bind => { child.bind(VSXExtensionsViewContainer).toSelf(); const viewContainer = child.get(VSXExtensionsViewContainer); const widgetManager = child.get(WidgetManager); - for (const id of [VSXExtensionsSourceOptions.SEARCH_RESULT, VSXExtensionsSourceOptions.INSTALLED, VSXExtensionsSourceOptions.BUILT_IN]) { + for (const id of [ + VSXExtensionsSourceOptions.SEARCH_RESULT, + VSXExtensionsSourceOptions.RECOMMENDED, + VSXExtensionsSourceOptions.INSTALLED, + VSXExtensionsSourceOptions.BUILT_IN, + ]) { const widget = await widgetManager.getOrCreateWidget(VSXExtensionsWidget.ID, { id }); viewContainer.addWidget(widget, { initiallyCollapsed: id === VSXExtensionsSourceOptions.BUILT_IN @@ -96,4 +103,6 @@ export default new ContainerModule(bind => { bind(VSXApiVersionProviderImpl).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(VSXApiVersionProviderImpl); bind(VSXApiVersionProvider).toService(VSXApiVersionProviderImpl); + bindExtensionPreferences(bind); + bindPreferenceProviderOverrides(bind, unbind); });