From a20b26d908c486aa8805a151b61bb7c490b92217 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 31 Dec 2020 23:54:26 +0100 Subject: [PATCH] enable workspace-scoped tasks Signed-off-by: Colin Grant --- CHANGELOG.md | 5 + .../api-tests/src/task-configurations.spec.js | 112 +++++++++++++ .../preferences/preference-configurations.ts | 4 + .../preferences/preference-contribution.ts | 24 ++- .../preferences/preference-provider.ts | 5 +- .../browser/preferences/preference-service.ts | 12 +- .../debug/src/browser/debug-schema-updater.ts | 3 + .../browser/folders-preferences-provider.ts | 8 +- .../preferences-json-schema-contribution.ts | 17 +- .../browser/section-preference-provider.ts | 4 +- .../user-configs-preference-provider.ts | 4 +- .../workspace-file-preference-provider.ts | 30 +++- .../browser/workspace-preference-provider.ts | 4 +- packages/task/src/browser/quick-open-task.ts | 120 +++++++------- .../src/browser/task-configuration-manager.ts | 139 ++++++++++------ .../src/browser/task-configuration-model.ts | 2 +- .../task/src/browser/task-configurations.ts | 4 +- packages/task/src/browser/task-preferences.ts | 1 + .../task/src/browser/task-schema-updater.ts | 11 +- .../src/browser/workspace-commands.ts | 5 + .../workspace-frontend-contribution.ts | 8 + .../src/browser/workspace-frontend-module.ts | 5 + .../src/browser/workspace-schema-updater.ts | 148 ++++++++++++++++++ .../src/browser/workspace-service.ts | 56 +++---- 24 files changed, 543 insertions(+), 188 deletions(-) create mode 100644 examples/api-tests/src/task-configurations.spec.js create mode 100644 packages/workspace/src/browser/workspace-schema-updater.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 700d9f0a2b2a6..24cfc12787b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,15 @@ - [filesystem] add text input and navigate up icon to file dialog [#8748](https://github.com/eclipse-theia/theia/pull/8748) - [core] updated connection status service to prevent false positive alerts about offline mode [#9068](https://github.com/eclipse-theia/theia/pull/9068) +- [tasks] add support for workspace-scoped task configurations. [#8917](https://github.com/eclipse-theia/theia/pull/8917) +- [workspace] add support for configurations outside the `settings` object and add `WorkspaceSchemaUpdater` to allow configurations sections to be contributed by extensions. [#8917](https://github.com/eclipse-theia/theia/pull/8917) [Breaking Changes:](#breaking_changes_1.12.0) - [filesystem] `FileDialog` and `LocationListRenderer` now require `FileService` to be passed into constructor for text-based file dialog navigation in browser [#8748](https://github.com/eclipse-theia/theia/pull/8748) +- [core] `PreferenceService` and `PreferenceProvider` `getConfigUri` and `getContainingConfigUri` methods accept `sectionName` argument to retrieve URI's for non-settings configurations. [#8917](https://github.com/eclipse-theia/theia/pull/8917) +- [tasks] `TaskConfigurationModel.scope` field now protected. `TaskConfigurationManager` setup changed to accommodate workspace-scoped tasks. [#8917](https://github.com/eclipse-theia/theia/pull/8917) +- [workspace] `WorkspaceData` interface modified and workspace file schema updated to allow for `tasks` outside of `settings` object. `WorkspaceData.buildWorkspaceData` `settings` argument now accepts an object with any of the keys of the workspace schema. [#8917](https://github.com/eclipse-theia/theia/pull/8917) ## v1.11.0 - 2/25/2021 diff --git a/examples/api-tests/src/task-configurations.spec.js b/examples/api-tests/src/task-configurations.spec.js new file mode 100644 index 0000000000000..294aec60cf929 --- /dev/null +++ b/examples/api-tests/src/task-configurations.spec.js @@ -0,0 +1,112 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +// @ts-check + +describe('The Task Configuration Manager', function () { + this.timeout(5000); + + const { assert } = chai; + + const { WorkspaceService } = require('@theia/workspace/lib/browser/workspace-service'); + const { TaskScope, TaskConfigurationScope } = require('@theia/task/lib/common/task-protocol'); + const { TaskConfigurationManager } = require('@theia/task/lib/browser/task-configuration-manager'); + const container = window.theia.container; + const workspaceService = container.get(WorkspaceService); + const taskConfigurationManager = container.get(TaskConfigurationManager); + + const baseWorkspaceURI = workspaceService.tryGetRoots()[0].resource; + const baseWorkspaceRoot = baseWorkspaceURI.toString(); + + const basicTaskConfig = { + label: 'task', + type: 'shell', + command: 'top', + }; + + /** @type {Set} */ + const scopesToClear = new Set(); + + describe('in a single-root workspace', () => { + beforeEach(() => clearTasks()); + after(() => clearTasks()); + + setAndRetrieveTasks(() => TaskScope.Global, 'user'); + setAndRetrieveTasks(() => TaskScope.Workspace, 'workspace'); + setAndRetrieveTasks(() => baseWorkspaceRoot, 'folder'); + }); + + async function clearTasks() { + await Promise.all(Array.from(scopesToClear, async scope => { + if (!!scope || scope === 0) { + await taskConfigurationManager.setTaskConfigurations(scope, []); + } + })); + scopesToClear.clear(); + } + + /** + * @param {() => TaskConfigurationScope} scopeGenerator a function to allow lazy evaluation of the second workspace root. + * @param {string} scopeLabel + * @param {boolean} only + */ + function setAndRetrieveTasks(scopeGenerator, scopeLabel, only = false) { + const testFunction = only ? it.only : it; + testFunction(`successfully handles ${scopeLabel} scope`, async () => { + const scope = scopeGenerator(); + scopesToClear.add(scope); + const initialTasks = taskConfigurationManager.getTasks(scope); + assert.deepEqual(initialTasks, []); + await taskConfigurationManager.setTaskConfigurations(scope, [basicTaskConfig]); + const newTasks = taskConfigurationManager.getTasks(scope); + assert.deepEqual(newTasks, [basicTaskConfig]); + }); + } + + /* UNCOMMENT TO RUN MULTI-ROOT TESTS */ + // const { FileService } = require('@theia/filesystem/lib/browser/file-service'); + // const { EnvVariablesServer } = require('@theia/core/lib/common/env-variables'); + // const URI = require('@theia/core/lib/common/uri').default; + + // const fileService = container.get(FileService); + // /** @type {EnvVariablesServer} */ + // const envVariables = container.get(EnvVariablesServer); + + // describe('in a multi-root workspace', () => { + // let secondWorkspaceRoot = ''; + // before(async () => { + // const configLocation = await envVariables.getConfigDirUri(); + // const secondWorkspaceRootURI = new URI(configLocation).parent.resolve(`test-root-${Date.now()}`); + // secondWorkspaceRoot = secondWorkspaceRootURI.toString(); + // await fileService.createFolder(secondWorkspaceRootURI); + // /** @type {Promise} */ + // const waitForEvent = new Promise(resolve => { + // const listener = taskConfigurationManager.onDidChangeTaskConfig(() => { + // listener.dispose(); + // resolve(); + // }); + // }); + // workspaceService.addRoot(secondWorkspaceRootURI); + // return waitForEvent; + // }); + // beforeEach(() => clearTasks()); + // after(() => clearTasks()); + // setAndRetrieveTasks(() => TaskScope.Global, 'user'); + // setAndRetrieveTasks(() => TaskScope.Workspace, 'workspace'); + // setAndRetrieveTasks(() => baseWorkspaceRoot, 'folder (1)'); + // setAndRetrieveTasks(() => secondWorkspaceRoot, 'folder (2)'); + // }); +}); diff --git a/packages/core/src/browser/preferences/preference-configurations.ts b/packages/core/src/browser/preferences/preference-configurations.ts index ada87a389c0c3..46feed84bfd36 100644 --- a/packages/core/src/browser/preferences/preference-configurations.ts +++ b/packages/core/src/browser/preferences/preference-configurations.ts @@ -55,6 +55,10 @@ export class PreferenceConfigurations { return this.getSectionNames().indexOf(name) !== -1; } + isAnyConfig(name: string): boolean { + return [...this.getSectionNames(), this.getConfigName()].includes(name); + } + isSectionUri(configUri: URI | undefined): boolean { return !!configUri && this.isSectionName(this.getName(configUri)); } diff --git a/packages/core/src/browser/preferences/preference-contribution.ts b/packages/core/src/browser/preferences/preference-contribution.ts index 566247a0a320c..c7e20579aa222 100644 --- a/packages/core/src/browser/preferences/preference-contribution.ts +++ b/packages/core/src/browser/preferences/preference-contribution.ts @@ -390,30 +390,24 @@ export class PreferenceSchemaProvider extends PreferenceProvider { return PreferenceSchemaProperties.is(value) && OVERRIDE_PROPERTY_PATTERN.test(name); } - private updateSchemaProps(key: string, property: PreferenceDataProperty): void { + protected updateSchemaProps(key: string, property: PreferenceDataProperty): void { this.combinedSchema.properties[key] = property; switch (property.scope) { - case PreferenceScope.Workspace: - this.workspaceSchema.properties[key] = property; - break; case PreferenceScope.Folder: this.folderSchema.properties[key] = property; + // Fall through. isValidInScope implies that User ⊃ Workspace ⊃ Folder, + // so anything we add to folder should be added to workspace, but not vice versa. + case PreferenceScope.Workspace: + this.workspaceSchema.properties[key] = property; break; } } - private removePropFromSchemas(key: string): void { - const scope = this.combinedSchema.properties[key].scope; - + protected removePropFromSchemas(key: string): void { + // If we remove a key from combined, it should also be removed from all narrower scopes. delete this.combinedSchema.properties[key]; - switch (scope) { - case PreferenceScope.Workspace: - delete this.workspaceSchema.properties[key]; - break; - case PreferenceScope.Folder: - delete this.folderSchema.properties[key]; - break; - } + delete this.workspaceSchema.properties[key]; + delete this.folderSchema.properties[key]; } } diff --git a/packages/core/src/browser/preferences/preference-provider.ts b/packages/core/src/browser/preferences/preference-provider.ts index 4e20612687139..497257d28870f 100644 --- a/packages/core/src/browser/preferences/preference-provider.ts +++ b/packages/core/src/browser/preferences/preference-provider.ts @@ -191,10 +191,11 @@ export abstract class PreferenceProvider implements Disposable { /** * Retrieve the configuration URI for the given resource URI. * @param resourceUri the uri of the resource or `undefined`. + * @param sectionName the section to return the URI for, e.g. `tasks` or `launch`. Defaults to settings. * * @returns the corresponding resource URI or `undefined` if there is no valid URI. */ - getConfigUri(resourceUri?: string): URI | undefined { + getConfigUri(resourceUri?: string, sectionName?: string): URI | undefined { return undefined; } @@ -205,7 +206,7 @@ export abstract class PreferenceProvider implements Disposable { * @returns the first valid configuration URI contained by the given resource `undefined` * if there is no valid configuration URI at all. */ - getContainingConfigUri?(resourceUri?: string): URI | undefined; + getContainingConfigUri?(resourceUri?: string, sectionName?: string): URI | undefined; static merge(source: JSONValue | undefined, target: JSONValue): JSONValue { if (source === undefined || !JSONExt.isObject(source)) { diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index edd7f98d5562b..6fd3e8f07daad 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -213,11 +213,12 @@ export interface PreferenceService extends Disposable { * * @param scope the PreferenceScope to query for. * @param resourceUri the optional uri of the resource-specific preference handling + * @param sectionName the optional preference section to query for. * * @returns the uri of the configuration resource for the given scope and optional resource uri it it exists, * `undefined` otherwise. */ - getConfigUri(scope: PreferenceScope, resourceUri?: string): URI | undefined; + getConfigUri(scope: PreferenceScope, resourceUri?: string, sectionName?: string): URI | undefined; } /** @@ -512,16 +513,15 @@ export class PreferenceServiceImpl implements PreferenceService { }; } - getConfigUri(scope: PreferenceScope, resourceUri?: string): URI | undefined { + getConfigUri(scope: PreferenceScope, resourceUri?: string, sectionName: string = this.configurations.getConfigName()): URI | undefined { const provider = this.getProvider(scope); - if (!provider) { + if (!provider || !this.configurations.isAnyConfig(sectionName)) { return undefined; } - const configUri = provider.getConfigUri(resourceUri); + const configUri = provider.getConfigUri(resourceUri, sectionName); if (configUri) { return configUri; } - return provider.getContainingConfigUri && provider.getContainingConfigUri(resourceUri); + return provider.getContainingConfigUri && provider.getContainingConfigUri(resourceUri, sectionName); } - } diff --git a/packages/debug/src/browser/debug-schema-updater.ts b/packages/debug/src/browser/debug-schema-updater.ts index b9fb4d7014730..353b5222cea95 100644 --- a/packages/debug/src/browser/debug-schema-updater.ts +++ b/packages/debug/src/browser/debug-schema-updater.ts @@ -22,6 +22,7 @@ import URI from '@theia/core/lib/common/uri'; import { DebugService } from '../common/debug-service'; import { debugPreferencesSchema } from './debug-preferences'; import { inputsSchema } from '@theia/variable-resolver/lib/browser/variable-input-schema'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; @injectable() export class DebugSchemaUpdater implements JsonSchemaContribution { @@ -29,6 +30,7 @@ export class DebugSchemaUpdater implements JsonSchemaContribution { protected readonly uri = new URI(launchSchemaId); @inject(InMemoryResources) protected readonly inmemoryResources: InMemoryResources; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(DebugService) protected readonly debug: DebugService; @postConstruct() @@ -41,6 +43,7 @@ export class DebugSchemaUpdater implements JsonSchemaContribution { fileMatch: ['launch.json'], url: this.uri.toString() }); + this.workspaceService.updateSchema('launch', { $ref: this.uri.toString() }); } async update(): Promise { diff --git a/packages/preferences/src/browser/folders-preferences-provider.ts b/packages/preferences/src/browser/folders-preferences-provider.ts index bee0fded0bb97..94aa5876c8fa1 100644 --- a/packages/preferences/src/browser/folders-preferences-provider.ts +++ b/packages/preferences/src/browser/folders-preferences-provider.ts @@ -77,20 +77,20 @@ export class FoldersPreferencesProvider extends PreferenceProvider { } } - getConfigUri(resourceUri?: string): URI | undefined { + getConfigUri(resourceUri?: string, sectionName: string = this.configurations.getConfigName()): URI | undefined { for (const provider of this.getFolderProviders(resourceUri)) { const configUri = provider.getConfigUri(resourceUri); - if (this.configurations.isConfigUri(configUri)) { + if (configUri && this.configurations.getName(configUri) === sectionName) { return configUri; } } return undefined; } - getContainingConfigUri(resourceUri?: string): URI | undefined { + getContainingConfigUri(resourceUri?: string, sectionName: string = this.configurations.getConfigName()): URI | undefined { for (const provider of this.getFolderProviders(resourceUri)) { const configUri = provider.getConfigUri(); - if (this.configurations.isConfigUri(configUri) && provider.contains(resourceUri)) { + if (provider.contains(resourceUri) && this.configurations.getName(configUri) === sectionName) { return configUri; } } diff --git a/packages/preferences/src/browser/preferences-json-schema-contribution.ts b/packages/preferences/src/browser/preferences-json-schema-contribution.ts index 646a9c9781b78..93c2deb488728 100644 --- a/packages/preferences/src/browser/preferences-json-schema-contribution.ts +++ b/packages/preferences/src/browser/preferences-json-schema-contribution.ts @@ -21,6 +21,7 @@ import { JsonSchemaRegisterContext, JsonSchemaContribution } from '@theia/core/l import { PreferenceSchemaProvider } from '@theia/core/lib/browser/preferences/preference-contribution'; import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; import { PreferenceScope } from '@theia/core/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; const PREFERENCE_URI_PREFIX = 'vscode://schemas/settings/'; const USER_STORAGE_PREFIX = 'user-storage:/'; @@ -38,12 +39,16 @@ export class PreferencesJsonSchemaContribution implements JsonSchemaContribution @inject(PreferenceConfigurations) protected readonly preferenceConfigurations: PreferenceConfigurations; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + registerSchemas(context: JsonSchemaRegisterContext): void { this.registerSchema(PreferenceScope.Default, context); this.registerSchema(PreferenceScope.User, context); this.registerSchema(PreferenceScope.Workspace, context); this.registerSchema(PreferenceScope.Folder, context); + this.workspaceService.updateSchema('settings', { $ref: this.getSchemaURIForScope(PreferenceScope.Workspace).toString() }); this.schemaProvider.onDidPreferenceSchemaChanged(() => this.updateInMemoryResources()); } @@ -60,16 +65,20 @@ export class PreferencesJsonSchemaContribution implements JsonSchemaContribution } private updateInMemoryResources(): void { - this.inmemoryResources.update(new URI(PREFERENCE_URI_PREFIX + PreferenceScope[PreferenceScope.Default].toLowerCase()), + this.inmemoryResources.update(this.getSchemaURIForScope(PreferenceScope.Default), this.serializeSchema(+PreferenceScope.Default)); - this.inmemoryResources.update(new URI(PREFERENCE_URI_PREFIX + PreferenceScope[PreferenceScope.User].toLowerCase()), + this.inmemoryResources.update(this.getSchemaURIForScope(PreferenceScope.User), this.serializeSchema(+PreferenceScope.User)); - this.inmemoryResources.update(new URI(PREFERENCE_URI_PREFIX + PreferenceScope[PreferenceScope.Workspace].toLowerCase()), + this.inmemoryResources.update(this.getSchemaURIForScope(PreferenceScope.Workspace), this.serializeSchema(+PreferenceScope.Workspace)); - this.inmemoryResources.update(new URI(PREFERENCE_URI_PREFIX + PreferenceScope[PreferenceScope.Folder].toLowerCase()), + this.inmemoryResources.update(this.getSchemaURIForScope(PreferenceScope.Folder), this.serializeSchema(+PreferenceScope.Folder)); } + private getSchemaURIForScope(scope: PreferenceScope): URI { + return new URI(PREFERENCE_URI_PREFIX + PreferenceScope[scope].toLowerCase()); + } + private getFileMatch(scope: string): string[] { const baseName = this.preferenceConfigurations.getConfigName() + '.json'; return [baseName, new URI(USER_STORAGE_PREFIX + scope).resolve(baseName).toString()]; diff --git a/packages/preferences/src/browser/section-preference-provider.ts b/packages/preferences/src/browser/section-preference-provider.ts index 41d47dc27f064..165d92cb7e34e 100644 --- a/packages/preferences/src/browser/section-preference-provider.ts +++ b/packages/preferences/src/browser/section-preference-provider.ts @@ -75,8 +75,8 @@ export abstract class SectionPreferenceProvider extends AbstractResourcePreferen if (preferenceName === this.section) { return []; } - if (preferenceName.startsWith(this.section + '.')) { - return [preferenceName.substr(this.section!.length + 1)]; + if (preferenceName.startsWith(`${this.section}.`)) { + return [preferenceName.slice(this.section.length + 1)]; } return undefined; } diff --git a/packages/preferences/src/browser/user-configs-preference-provider.ts b/packages/preferences/src/browser/user-configs-preference-provider.ts index e797481fa452f..321ca2ead0b72 100644 --- a/packages/preferences/src/browser/user-configs-preference-provider.ts +++ b/packages/preferences/src/browser/user-configs-preference-provider.ts @@ -59,10 +59,10 @@ export class UserConfigsPreferenceProvider extends PreferenceProvider { } } - getConfigUri(resourceUri?: string): URI | undefined { + getConfigUri(resourceUri?: string, sectionName: string = this.configurations.getConfigName()): URI | undefined { for (const provider of this.providers.values()) { const configUri = provider.getConfigUri(resourceUri); - if (this.configurations.isConfigUri(configUri)) { + if (configUri && this.configurations.getName(configUri) === sectionName) { return configUri; } } diff --git a/packages/preferences/src/browser/workspace-file-preference-provider.ts b/packages/preferences/src/browser/workspace-file-preference-provider.ts index fa4e0fe181836..218d776a8bd89 100644 --- a/packages/preferences/src/browser/workspace-file-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-file-preference-provider.ts @@ -37,6 +37,8 @@ export class WorkspaceFilePreferenceProvider extends AbstractResourcePreferenceP @inject(WorkspaceFilePreferenceProviderOptions) protected readonly options: WorkspaceFilePreferenceProviderOptions; + protected sectionsInsideSettings = new Set(); + protected getUri(): URI { return this.options.workspaceUri; } @@ -45,12 +47,38 @@ export class WorkspaceFilePreferenceProvider extends AbstractResourcePreferenceP protected parse(content: string): any { const data = super.parse(content); if (WorkspaceData.is(data)) { - return data.settings || {}; + const settings = { ...data.settings }; + for (const key of this.configurations.getSectionNames().filter(name => name !== 'settings')) { + // If the user has written configuration inside the "settings" object, we will respect that. + if (settings[key]) { + this.sectionsInsideSettings.add(key); + } + // Favor sections outside the "settings" object to agree with VSCode behavior + if (data[key]) { + settings[key] = data[key]; + this.sectionsInsideSettings.delete(key); + } + } + return settings; } return {}; } protected getPath(preferenceName: string): string[] { + const firstSegment = preferenceName.split('.')[0]; + if (firstSegment && this.configurations.isSectionName(firstSegment)) { + // Default to writing sections outside the "settings" object. + const path = [firstSegment]; + const pathRemainder = preferenceName.slice(firstSegment.length + 1); + if (pathRemainder) { + path.push(pathRemainder); + } + // If the user has already written this section inside the "settings" object, modify it there. + if (this.sectionsInsideSettings.has(firstSegment)) { + path.unshift('settings'); + } + return path; + } return ['settings', preferenceName]; } diff --git a/packages/preferences/src/browser/workspace-preference-provider.ts b/packages/preferences/src/browser/workspace-preference-provider.ts index 41cbfd9d7271c..46ade70a98195 100644 --- a/packages/preferences/src/browser/workspace-preference-provider.ts +++ b/packages/preferences/src/browser/workspace-preference-provider.ts @@ -42,9 +42,9 @@ export class WorkspacePreferenceProvider extends PreferenceProvider { this.workspaceService.onWorkspaceLocationChanged(() => this.ensureDelegateUpToDate()); } - getConfigUri(resourceUri: string | undefined = this.ensureResourceUri()): URI | undefined { + getConfigUri(resourceUri: string | undefined = this.ensureResourceUri(), sectionName?: string): URI | undefined { const delegate = this.delegate; - return delegate && delegate.getConfigUri(resourceUri); + return delegate && delegate.getConfigUri(resourceUri, sectionName); } protected _delegate: PreferenceProvider | undefined; diff --git a/packages/task/src/browser/quick-open-task.ts b/packages/task/src/browser/quick-open-task.ts index 6486205fde951..69cc242c3ed37 100644 --- a/packages/task/src/browser/quick-open-task.ts +++ b/packages/task/src/browser/quick-open-task.ts @@ -234,77 +234,41 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { async configure(): Promise { this.items = []; this.actionProvider = undefined; - const isMulti: boolean = this.workspaceService.isMultiRootWorkspaceOpened; const token: number = this.taskService.startUserAction(); const configuredTasks = await this.taskService.getConfiguredTasks(token); const providedTasks = await this.taskService.getProvidedTasks(token); - - // check if tasks.json exists. If not, display "Create tasks.json file from template" - // If tasks.json exists and empty, display 'Open tasks.json file' - let isFirstGroup = true; const { filteredConfiguredTasks, filteredProvidedTasks } = this.getFilteredTasks([], configuredTasks, providedTasks); const groupedTasks = this.getGroupedTasksByWorkspaceFolder([...filteredConfiguredTasks, ...filteredProvidedTasks]); - if (groupedTasks.has(TaskScope.Global.toString())) { - const configs = groupedTasks.get(TaskScope.Global.toString())!; - this.items.push( - ...configs.map(taskConfig => { - const item = new TaskConfigureQuickOpenItem( - token, - taskConfig, - this.taskService, - this.taskNameResolver, - this.workspaceService, - isMulti, - { showBorder: false } - ); - item['taskDefinitionRegistry'] = this.taskDefinitionRegistry; - return item; - }) - ); - isFirstGroup = false; + + const scopes: TaskConfigurationScope[] = [TaskScope.Global]; + if (this.workspaceService.opened) { + const roots = await this.workspaceService.roots; + scopes.push(...roots.map(rootStat => rootStat.resource.toString())); + if (this.workspaceService.saved) { + scopes.push(TaskScope.Workspace); + } } - const rootUris = (await this.workspaceService.roots).map(rootStat => rootStat.resource.toString()); - for (const rootFolder of rootUris) { - const folderName = new URI(rootFolder).displayName; - if (groupedTasks.has(rootFolder)) { - const configs = groupedTasks.get(rootFolder.toString())!; - this.items.push( - ...configs.map((taskConfig, index) => { - const item = new TaskConfigureQuickOpenItem( - token, - taskConfig, - this.taskService, - this.taskNameResolver, - this.workspaceService, - isMulti, - { - groupLabel: index === 0 && isMulti ? folderName : '', - showBorder: !isFirstGroup && index === 0 - } - ); - item['taskDefinitionRegistry'] = this.taskDefinitionRegistry; - return item; - }) - ); + let isFirstGroup = true; + let groupLabel = ''; + const optionsGenerator = (index: number): QuickOpenGroupItemOptions => ({ + showBorder: !isFirstGroup && index === 0, + groupLabel: index === 0 ? groupLabel : '', + description: groupLabel, + }); + + for (const scope of scopes) { + groupLabel = typeof scope === 'string' ? this.labelProvider.getName(new URI(scope)) : TaskScope[scope]; + + const configs = groupedTasks.get(scope.toString()); + if (configs?.length) { + this.items.push(...this.getTaskConfigureQuickOpenItems(configs, token, optionsGenerator)); } else { - const { configUri } = this.preferences.resolve('tasks', [], rootFolder); - const existTaskConfigFile = !!configUri; - this.items.push(new QuickOpenGroupItem({ - label: existTaskConfigFile ? 'Open tasks.json file' : 'Create tasks.json file from template', - run: (mode: QuickOpenMode): boolean => { - if (mode !== QuickOpenMode.OPEN) { - return false; - } - setTimeout(() => this.taskConfigurationManager.openConfiguration(rootFolder)); - return true; - }, - showBorder: !isFirstGroup, - groupLabel: isMulti ? folderName : '' - })); + this.items.push(this.getOpenFileItem(scope, optionsGenerator.bind(this, 0))); } + isFirstGroup = false; } @@ -322,6 +286,40 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { }); } + protected getTaskConfigureQuickOpenItems( + configs: TaskConfiguration[], + token: number, + optionsGenerator: (index: number) => QuickOpenGroupItemOptions + ): TaskConfigureQuickOpenItem[] { + return configs.map((taskConfig, index) => { + const item = new TaskConfigureQuickOpenItem( + token, + taskConfig, + this.taskService, + this.taskNameResolver, + this.workspaceService, + this.workspaceService.isMultiRootWorkspaceOpened, + optionsGenerator(index), + ); + item['taskDefinitionRegistry'] = this.taskDefinitionRegistry; + return item; + }); + } + + protected getOpenFileItem(scope: TaskConfigurationScope, optionsGenerator: () => QuickOpenGroupItemOptions): QuickOpenGroupItem { + return new QuickOpenGroupItem({ + label: 'Configure new task.', + run: (mode: QuickOpenMode): boolean => { + if (mode !== QuickOpenMode.OPEN) { + return false; + } + setTimeout(() => this.taskConfigurationManager.openConfiguration(scope)); + return true; + }, + ...optionsGenerator(), + }); + } + async runBuildOrTestTask(buildOrTestType: 'build' | 'test'): Promise { const shouldRunBuildTask = buildOrTestType === 'build'; const token: number = this.taskService.startUserAction(); @@ -502,7 +500,7 @@ export class TaskRunQuickOpenItem extends QuickOpenGroupItem { } getDescription(): string { - return renderScope(this.task._scope, this.isMulti); + return this.options.description || renderScope(this.task._scope, this.isMulti); } run(mode: QuickOpenMode): boolean { diff --git a/packages/task/src/browser/task-configuration-manager.ts b/packages/task/src/browser/task-configuration-manager.ts index 06fd156fb5fbe..d1e37313b7dce 100644 --- a/packages/task/src/browser/task-configuration-manager.ts +++ b/packages/task/src/browser/task-configuration-manager.ts @@ -14,12 +14,13 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as jsoncparser from 'jsonc-parser'; import debounce = require('p-debounce'); import { inject, injectable, postConstruct, named } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { EditorManager, EditorWidget } from '@theia/editor/lib/browser'; -import { PreferenceScope, PreferenceProvider } from '@theia/core/lib/browser'; +import { PreferenceScope, PreferenceProvider, PreferenceService } from '@theia/core/lib/browser'; import { QuickPickService } from '@theia/core/lib/common/quick-pick-service'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { TaskConfigurationModel } from './task-configuration-model'; @@ -29,6 +30,8 @@ import { WorkspaceVariableContribution } from '@theia/workspace/lib/browser/work import { FileChangeType } from '@theia/filesystem/lib/common/filesystem-watcher-protocol'; import { PreferenceConfigurations } from '@theia/core/lib/browser/preferences/preference-configurations'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { DisposableCollection } from '@theia/core/lib/common'; +import { TaskSchemaUpdater } from './task-schema-updater'; export interface TasksChange { scope: TaskConfigurationScope; @@ -53,12 +56,21 @@ export class TaskConfigurationManager { @inject(FileService) protected readonly fileService: FileService; + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @inject(TaskSchemaUpdater) + protected readonly taskSchemaProvider: TaskSchemaUpdater; + @inject(PreferenceProvider) @named(PreferenceScope.Folder) protected readonly folderPreferences: PreferenceProvider; @inject(PreferenceProvider) @named(PreferenceScope.User) protected readonly userPreferences: PreferenceProvider; + @inject(PreferenceProvider) @named(PreferenceScope.Workspace) + protected readonly workspacePreferences: PreferenceProvider; + @inject(PreferenceConfigurations) protected readonly preferenceConfigurations: PreferenceConfigurations; @@ -71,14 +83,12 @@ export class TaskConfigurationManager { protected readonly onDidChangeTaskConfigEmitter = new Emitter(); readonly onDidChangeTaskConfig: Event = this.onDidChangeTaskConfigEmitter.event; - protected readonly models = new Map(); - protected userModel: TaskConfigurationModel; + protected readonly models = new Map(); + protected workspaceDelegate: PreferenceProvider; @postConstruct() protected async init(): Promise { - this.userModel = new TaskConfigurationModel(TaskScope.Global, this.userPreferences); - this.userModel.onDidChange(() => this.onDidChangeTaskConfigEmitter.fire({ scope: TaskScope.Global, type: FileChangeType.UPDATED })); - this.updateModels(); + this.createModels(); this.folderPreferences.onDidPreferencesChanged(e => { if (e['tasks']) { this.updateModels(); @@ -89,9 +99,21 @@ export class TaskConfigurationManager { }); } + protected createModels(): void { + const userModel = new TaskConfigurationModel(TaskScope.Global, this.userPreferences); + userModel.onDidChange(() => this.onDidChangeTaskConfigEmitter.fire({ scope: TaskScope.Global, type: FileChangeType.UPDATED })); + this.models.set(TaskScope.Global, userModel); + + this.updateModels(); + } + protected updateModels = debounce(async () => { const roots = await this.workspaceService.roots; - const toDelete = new Set(this.models.keys()); + const toDelete = new Set( + [...this.models.keys()] + .filter(key => key !== TaskScope.Global && key !== TaskScope.Workspace) + ); + this.updateWorkspaceModel(); for (const rootStat of roots) { const key = rootStat.resource.toString(); toDelete.delete(key); @@ -113,29 +135,19 @@ export class TaskConfigurationManager { }, 500); getTasks(scope: TaskConfigurationScope): (TaskCustomization | TaskConfiguration)[] { - if (typeof scope === 'string' && this.models.has(scope)) { - const taskPrefModel = this.models.get(scope)!; - return taskPrefModel.configurations; - } - return this.userModel.configurations; + return this.getModel(scope)?.configurations ?? []; } getTask(name: string, scope: TaskConfigurationScope): TaskCustomization | TaskConfiguration | undefined { - const taskPrefModel = this.getModel(scope); - if (taskPrefModel) { - for (const configuration of taskPrefModel.configurations) { - if (configuration.name === name) { - return configuration; - } - } - } - return this.userModel.configurations.find(configuration => configuration.name === 'name'); + return this.getTasks(scope).find((configuration: TaskCustomization | TaskConfiguration) => configuration.name === name); } async openConfiguration(scope: TaskConfigurationScope): Promise { const taskPrefModel = this.getModel(scope); - if (taskPrefModel) { - await this.doOpen(taskPrefModel); + const maybeURI = typeof scope === 'string' ? scope : undefined; + const configURI = this.preferenceService.getConfigUri(this.getMatchingPreferenceScope(scope), maybeURI, 'tasks'); + if (taskPrefModel && configURI) { + await this.doOpen(taskPrefModel, configURI); } } @@ -156,43 +168,46 @@ export class TaskConfigurationManager { return false; } - private getModel(scope: TaskConfigurationScope): TaskConfigurationModel | undefined { - if (!scope) { - return undefined; - } - for (const model of this.models.values()) { - if (model.scope === scope) { - return model; - } - } - if (scope === TaskScope.Global) { - return this.userModel; - } + protected getModel(scope: TaskConfigurationScope): TaskConfigurationModel | undefined { + return this.models.get(scope); } - protected async doOpen(model: TaskConfigurationModel): Promise { - let uri = model.uri; - if (!uri) { - uri = await this.doCreate(model); - } - if (uri) { - return this.editorManager.open(uri, { - mode: 'activate' - }); + protected async doOpen(model: TaskConfigurationModel, configURI: URI): Promise { + if (!model.uri) { + // The file has not yet been created. + await this.doCreate(model, configURI); } + return this.editorManager.open(configURI, { + mode: 'activate' + }); } - protected async doCreate(model: TaskConfigurationModel): Promise { + protected async doCreate(model: TaskConfigurationModel, configURI: URI): Promise { const content = await this.getInitialConfigurationContent(); if (content) { - await model.preferences.setPreference('tasks', {}, model.getWorkspaceFolder()); // create dummy tasks.json in the correct place - const { configUri } = model.preferences.resolve('tasks', model.getWorkspaceFolder()); // get uri to write content to it - - if (!configUri || configUri.path.base !== 'tasks.json') { - return undefined; + // All scopes but workspace. + if (this.preferenceConfigurations.getName(configURI) === 'tasks') { + await this.fileService.write(configURI, content); + } else { + let taskContent: object; + try { + taskContent = jsoncparser.parse(content); + } catch { + taskContent = this.taskSchemaProvider.getTaskSchema().default ?? {}; + } + await model.preferences.setPreference('tasks', taskContent); } - await this.fileService.write(configUri, content); - return configUri; + } + } + + protected getMatchingPreferenceScope(scope: TaskConfigurationScope): PreferenceScope { + switch (scope) { + case TaskScope.Global: + return PreferenceScope.User; + case TaskScope.Workspace: + return PreferenceScope.Workspace; + default: + return PreferenceScope.Folder; } } @@ -204,6 +219,26 @@ export class TaskConfigurationManager { return selected.content; } } + + protected readonly toDisposeOnDelegateChange = new DisposableCollection(); + protected updateWorkspaceModel(): void { + const newDelegate = this.workspaceService.saved ? this.workspacePreferences : this.folderPreferences; + const effectiveScope = this.workspaceService.saved ? TaskScope.Workspace : this.workspaceService.tryGetRoots()[0]?.resource.toString(); + if (newDelegate !== this.workspaceDelegate) { + this.workspaceDelegate = newDelegate; + this.toDisposeOnDelegateChange.dispose(); + + const workspaceModel = new TaskConfigurationModel(effectiveScope, newDelegate); + this.toDisposeOnDelegateChange.push(workspaceModel); + // If the delegate is the folder preference provider, its events will be relayed via the folder scope models. + if (newDelegate === this.workspacePreferences) { + this.toDisposeOnDelegateChange.push(workspaceModel.onDidChange(() => { + this.onDidChangeTaskConfigEmitter.fire({ scope: TaskScope.Workspace, type: FileChangeType.UPDATED }); + })); + } + this.models.set(TaskScope.Workspace, workspaceModel); + } + } } export namespace TaskConfigurationManager { diff --git a/packages/task/src/browser/task-configuration-model.ts b/packages/task/src/browser/task-configuration-model.ts index a2e5c4bf180a2..48b1712c01766 100644 --- a/packages/task/src/browser/task-configuration-model.ts +++ b/packages/task/src/browser/task-configuration-model.ts @@ -36,7 +36,7 @@ export class TaskConfigurationModel implements Disposable { ); constructor( - readonly scope: TaskConfigurationScope, + protected readonly scope: TaskConfigurationScope, readonly preferences: PreferenceProvider ) { this.reconcile(); diff --git a/packages/task/src/browser/task-configurations.ts b/packages/task/src/browser/task-configurations.ts index 10c9efc117a22..925dd8cb7ed41 100644 --- a/packages/task/src/browser/task-configurations.ts +++ b/packages/task/src/browser/task-configurations.ts @@ -302,8 +302,8 @@ export class TaskConfigurations implements Disposable { /** Adds given task to a config file and opens the file to provide ability to edit task configuration. */ async configure(token: number, task: TaskConfiguration): Promise { const scope = task._scope; - if (scope === TaskScope.Global) { - return this.openUserTasks(); + if (scope === TaskScope.Global || scope === TaskScope.Workspace) { + return this.taskConfigurationManager.openConfiguration(scope); } else if (typeof scope !== 'string') { console.error('Global task cannot be customized'); // TODO detected tasks of scope workspace or user could be customized in those preferences. diff --git a/packages/task/src/browser/task-preferences.ts b/packages/task/src/browser/task-preferences.ts index 90a4ac5576b34..9be1987f3f664 100644 --- a/packages/task/src/browser/task-preferences.ts +++ b/packages/task/src/browser/task-preferences.ts @@ -27,6 +27,7 @@ export const taskPreferencesSchema: PreferenceSchema = { $ref: taskSchemaId, description: 'Task definition file', defaultValue: { + version: '2.0.0', tasks: [] } } diff --git a/packages/task/src/browser/task-schema-updater.ts b/packages/task/src/browser/task-schema-updater.ts index 91fd1653ac501..8670cfe791dd1 100644 --- a/packages/task/src/browser/task-schema-updater.ts +++ b/packages/task/src/browser/task-schema-updater.ts @@ -32,6 +32,7 @@ import { ProblemMatcherRegistry } from './task-problem-matcher-registry'; import { TaskDefinitionRegistry } from './task-definition-registry'; import { TaskServer } from '../common'; import { UserStorageUri } from '@theia/userstorage/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; export const taskSchemaId = 'vscode://schemas/tasks'; @@ -50,6 +51,9 @@ export class TaskSchemaUpdater implements JsonSchemaContribution { @inject(TaskServer) protected readonly taskServer: TaskServer; + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + protected readonly onDidChangeTaskSchemaEmitter = new Emitter(); readonly onDidChangeTaskSchema = this.onDidChangeTaskSchemaEmitter.event; @@ -77,6 +81,7 @@ export class TaskSchemaUpdater implements JsonSchemaContribution { fileMatch: ['tasks.json', UserStorageUri.resolve('tasks.json').toString()], url: this.uri.toString() }); + this.workspaceService.updateSchema('tasks', { $ref: this.uri.toString() }); } readonly update = debounce(() => this.doUpdate(), 0); @@ -181,12 +186,14 @@ export class TaskSchemaUpdater implements JsonSchemaContribution { } /** Returns the task's JSON schema */ - protected getTaskSchema(): IJSONSchema { + getTaskSchema(): IJSONSchema { return { type: 'object', + default: { version: '2.0.0', tasks: [] }, properties: { version: { - type: 'string' + type: 'string', + default: '2.0.0' }, tasks: { type: 'array', diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 3c1c0751ab176..bdaf25ac6b9eb 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -129,6 +129,11 @@ export namespace WorkspaceCommands { category: WORKSPACE_CATEGORY, label: 'Save Workspace As...' }; + export const OPEN_WORKSPACE_FILE: Command = { + id: 'workspace:openConfigFile', + category: WORKSPACE_CATEGORY, + label: 'Open Workspace Configuration File' + }; export const SAVE_AS: Command = { id: 'file.saveAs', category: 'File', diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts index 329e23756efac..c57d9322c87e6 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -153,6 +153,14 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi UriAwareCommandHandler.MonoSelect(this.selectionService, { execute: (uri: URI) => this.saveAs(uri), })); + commands.registerCommand(WorkspaceCommands.OPEN_WORKSPACE_FILE, { + isEnabled: () => this.workspaceService.saved, + execute: () => { + if (this.workspaceService.saved && this.workspaceService.workspace) { + open(this.openerService, this.workspaceService.workspace.resource); + } + } + }); } registerMenus(menus: MenuModelRegistry): void { diff --git a/packages/workspace/src/browser/workspace-frontend-module.ts b/packages/workspace/src/browser/workspace-frontend-module.ts index 4e2be49c51c14..b077e7cb0bc97 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -44,6 +44,8 @@ import { WorkspaceDuplicateHandler } from './workspace-duplicate-handler'; import { WorkspaceUtils } from './workspace-utils'; import { WorkspaceCompareHandler } from './workspace-compare-handler'; import { DiffService } from './diff-service'; +import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store'; +import { WorkspaceSchemaUpdater } from './workspace-schema-updater'; export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { bindWorkspacePreferences(bind); @@ -91,4 +93,7 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un bind(QuickOpenWorkspace).toSelf().inSingletonScope(); bind(WorkspaceUtils).toSelf().inSingletonScope(); + + bind(WorkspaceSchemaUpdater).toSelf().inSingletonScope(); + bind(JsonSchemaContribution).toService(WorkspaceSchemaUpdater); }); diff --git a/packages/workspace/src/browser/workspace-schema-updater.ts b/packages/workspace/src/browser/workspace-schema-updater.ts new file mode 100644 index 0000000000000..073c8cc7241db --- /dev/null +++ b/packages/workspace/src/browser/workspace-schema-updater.ts @@ -0,0 +1,148 @@ +/******************************************************************************** + * 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 'inversify'; +import { JsonSchemaContribution, JsonSchemaRegisterContext } from '@theia/core/lib/browser/json-schema-store'; +import { InMemoryResources } from '@theia/core/lib/common'; +import { IJSONSchema } from '@theia/core/lib/common/json-schema'; +import URI from '@theia/core/lib/common/uri'; +import { Deferred } from '@theia/core/lib/common/promise-util'; + +export interface SchemaUpdateMessage { + key: string, + schema?: IJSONSchema, + deferred: Deferred; +} + +export namespace AddKeyMessage { + export const is = (message: SchemaUpdateMessage | undefined): message is Required => !!message && message.schema !== undefined; +} + +@injectable() +export class WorkspaceSchemaUpdater implements JsonSchemaContribution { + + protected readonly uri = new URI(workspaceSchemaId); + protected readonly editQueue: SchemaUpdateMessage[] = []; + protected safeToHandleQueue = new Deferred(); + + @inject(InMemoryResources) protected readonly inmemoryResources: InMemoryResources; + + @postConstruct() + protected init(): void { + this.inmemoryResources.add(this.uri, JSON.stringify(workspaceSchema)); + this.safeToHandleQueue.resolve(); + } + + registerSchemas(context: JsonSchemaRegisterContext): void { + context.registerSchema({ + fileMatch: ['*.theia-workspace', '*.code-workspace'], + url: this.uri.toString() + }); + } + + protected async retrieveCurrent(): Promise { + const current = await this.inmemoryResources.resolve(this.uri).readContents(); + + const content = JSON.parse(current); + + if (!WorkspaceSchema.is(content)) { + throw new Error('Failed to retrieve current workspace schema.'); + } + + return content; + } + + async updateSchema(message: Omit): Promise { + const doHandle = this.editQueue.length === 0; + const deferred = new Deferred(); + this.editQueue.push({ ...message, deferred }); + if (doHandle) { + this.handleQueue(); + } + return deferred.promise; + } + + protected async handleQueue(): Promise { + await this.safeToHandleQueue.promise; + this.safeToHandleQueue = new Deferred(); + const cache = await this.retrieveCurrent(); + while (this.editQueue.length) { + const nextMessage = this.editQueue.shift(); + if (AddKeyMessage.is(nextMessage)) { + this.addKey(nextMessage, cache); + } else if (nextMessage) { + this.removeKey(nextMessage, cache); + } + } + this.inmemoryResources.update(this.uri, JSON.stringify(cache)); + this.safeToHandleQueue.resolve(); + } + + protected addKey({ key, schema, deferred }: Required, cache: WorkspaceSchema): void { + if (key in cache.properties) { + return deferred.resolve(false); + } + + cache.properties[key] = schema; + deferred.resolve(true); + } + + protected removeKey({ key, deferred }: SchemaUpdateMessage, cache: WorkspaceSchema): void { + const canDelete = !cache.required.includes(key); + if (!canDelete) { + return deferred.resolve(false); + } + + const keyPresent = delete cache.properties[key]; + deferred.resolve(keyPresent); + } +} + +export type WorkspaceSchema = Required>; + +export namespace WorkspaceSchema { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export const is = (candidate: any): candidate is WorkspaceSchema => !!candidate + && typeof candidate === 'object' + && 'properties' in candidate + && typeof candidate.properties === 'object' + && 'required' in candidate + && Array.isArray(candidate.required); +} + +export const workspaceSchemaId = 'vscode://schemas/workspace'; +export const workspaceSchema: IJSONSchema = { + $id: workspaceSchemaId, + type: 'object', + title: 'Workspace File', + required: ['folders'], + default: { folders: [{ path: '' }], settings: {} }, + properties: { + folders: { + description: 'Root folders in the workspace', + type: 'array', + items: { + type: 'object', + properties: { + path: { + type: 'string', + } + }, + required: ['path'] + } + } + }, +}; diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index 5d6cf8de5e125..39c8cc9729b89 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -31,6 +31,8 @@ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/front import { FileStat, BaseStat } from '@theia/filesystem/lib/common/files'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileSystemPreferences } from '@theia/filesystem/lib/browser'; +import { workspaceSchema, WorkspaceSchemaUpdater } from './workspace-schema-updater'; +import { IJSONSchema } from '@theia/core/lib/common/json-schema'; /** * The workspace service. @@ -76,6 +78,9 @@ export class WorkspaceService implements FrontendApplicationContribution { @inject(FileSystemPreferences) protected readonly fsPreferences: FileSystemPreferences; + @inject(WorkspaceSchemaUpdater) + protected readonly schemaUpdater: WorkspaceSchemaUpdater; + protected applicationName: string; @postConstruct() @@ -373,7 +378,7 @@ export class WorkspaceService implements FrontendApplicationContribution { this._workspace = await this.writeWorkspaceFile(this._workspace, WorkspaceData.buildWorkspaceData( this._roots.filter(root => uris.findIndex(u => u.toString() === root.resource.toString()) < 0), - workspaceData!.settings + workspaceData ) ); } @@ -402,7 +407,7 @@ export class WorkspaceService implements FrontendApplicationContribution { await this.save(untitledWorkspace); } const currentData = await this.getWorkspaceDataFromFile(); - const newData = WorkspaceData.buildWorkspaceData(roots, currentData && currentData.settings); + const newData = WorkspaceData.buildWorkspaceData(roots, currentData); await this.writeWorkspaceFile(this._workspace, newData); return toRemove.map(root => new URI(root)); } @@ -547,7 +552,7 @@ export class WorkspaceService implements FrontendApplicationContribution { } let stat = await this.toFileStat(resource); Object.assign(workspaceData, await this.getWorkspaceDataFromFile()); - stat = await this.writeWorkspaceFile(stat, WorkspaceData.buildWorkspaceData(this._roots, workspaceData ? workspaceData.settings : undefined)); + stat = await this.writeWorkspaceFile(stat, WorkspaceData.buildWorkspaceData(this._roots, workspaceData)); await this.server.setMostRecentlyUsedWorkspace(resource.toString()); await this.setWorkspace(stat); this.onWorkspaceLocationChangedEmitter.fire(stat); @@ -636,6 +641,14 @@ export class WorkspaceService implements FrontendApplicationContribution { return fileStat.resource.path.ext === `.${THEIA_EXT}` || fileStat.resource.path.ext === `.${VSCODE_EXT}`; } + /** + * + * @param key the property key under which to store the schema (e.g. tasks, launch) + * @param schema the schema for the property. If none is supplied, the update is treated as a deletion. + */ + async updateSchema(key: string, schema?: IJSONSchema): Promise { + return this.schemaUpdater.updateSchema({ key, schema }); + } } export interface WorkspaceInput { @@ -650,33 +663,11 @@ export interface WorkspaceInput { export interface WorkspaceData { folders: Array<{ path: string, name?: string }>; // eslint-disable-next-line @typescript-eslint/no-explicit-any - settings?: { [id: string]: any }; + [key: string]: { [id: string]: any }; } export namespace WorkspaceData { - const validateSchema = new Ajv().compile({ - type: 'object', - properties: { - folders: { - description: 'Root folders in the workspace', - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - } - }, - required: ['path'] - } - }, - settings: { - description: 'Workspace preferences', - type: 'object' - } - }, - required: ['folders'] - }); + const validateSchema = new Ajv().compile(workspaceSchema); // eslint-disable-next-line @typescript-eslint/no-explicit-any export function is(data: any): data is WorkspaceData { @@ -684,7 +675,7 @@ export namespace WorkspaceData { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - export function buildWorkspaceData(folders: string[] | FileStat[], settings: { [id: string]: any } | undefined): WorkspaceData { + export function buildWorkspaceData(folders: string[] | FileStat[], additionalFields?: Partial): WorkspaceData { let roots: string[] = []; if (folders.length > 0) { if (typeof folders[0] !== 'string') { @@ -696,8 +687,9 @@ export namespace WorkspaceData { const data: WorkspaceData = { folders: roots.map(folder => ({ path: folder })) }; - if (settings) { - data.settings = settings; + if (additionalFields) { + delete additionalFields.folders; + Object.assign(data, additionalFields); } return data; } @@ -714,7 +706,7 @@ export namespace WorkspaceData { folderUris.push(folderUri.toString()); } } - return buildWorkspaceData(folderUris, data.settings); + return buildWorkspaceData(folderUris, data); } export function transformToAbsolute(data: WorkspaceData, workspaceFile?: BaseStat): WorkspaceData { @@ -729,7 +721,7 @@ export namespace WorkspaceData { } } - return Object.assign(data, buildWorkspaceData(folders, data.settings)); + return Object.assign(data, buildWorkspaceData(folders, data)); } return data; }