diff --git a/CHANGELOG.md b/CHANGELOG.md index 94655c9083e19..efced67b17fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change Log +## v0.10.0 + +- [task] added support for VS Code task contribution points: `taskDefinitions`, `problemMatchers`, and `problemPatterns` +- [task] added multi-root support to "configure task" and customizing tasks in `tasks.json` +- [task] changed the way that "configure task" copies the entire task config, to only writting properties that define the detected task plus `problemMatcher`, into `tasks.json` +- [task] fixed the problem where a detected task can be customized more than once +- [task] displayed the customized tasks as "configured tasks" in the task quick open +- [task] allowed users to override any task properties other than the ones used in the task definition + +Breaking changes: + +- [task] `TaskService.getConfiguredTasks()` returns `Promise` instead of `TaskConfiguration[]`. + ## v0.9.0 - [core] added `theia-widget-noInfo` css class to be used by widgets when displaying no information messages [#5717](https://github.com/theia-ide/theia/pull/5717) - [core] added additional options to the tree search input [#5566](https://github.com/theia-ide/theia/pull/5566) diff --git a/packages/task/src/browser/provided-task-configurations.spec.ts b/packages/task/src/browser/provided-task-configurations.spec.ts index 6849cc8257c5b..151155bd3e0ca 100644 --- a/packages/task/src/browser/provided-task-configurations.spec.ts +++ b/packages/task/src/browser/provided-task-configurations.spec.ts @@ -17,6 +17,7 @@ import { assert } from 'chai'; import { Container } from 'inversify'; import { ProvidedTaskConfigurations } from './provided-task-configurations'; +import { TaskDefinitionRegistry } from './task-definition-registry'; import { TaskProviderRegistry } from './task-contribution'; import { TaskConfiguration } from '../common'; @@ -26,6 +27,7 @@ describe('provided-task-configurations', () => { container = new Container(); container.bind(ProvidedTaskConfigurations).toSelf().inSingletonScope(); container.bind(TaskProviderRegistry).toSelf().inSingletonScope(); + container.bind(TaskDefinitionRegistry).toSelf().inSingletonScope(); }); it('provided-task-search', async () => { diff --git a/packages/task/src/browser/provided-task-configurations.ts b/packages/task/src/browser/provided-task-configurations.ts index 2369b11060fee..11dfee12c2432 100644 --- a/packages/task/src/browser/provided-task-configurations.ts +++ b/packages/task/src/browser/provided-task-configurations.ts @@ -15,8 +15,10 @@ ********************************************************************************/ import { inject, injectable } from 'inversify'; -import { TaskConfiguration } from '../common/task-protocol'; import { TaskProviderRegistry } from './task-contribution'; +import { TaskDefinitionRegistry } from './task-definition-registry'; +import { TaskConfiguration, TaskCustomization } from '../common'; +import URI from '@theia/core/lib/common/uri'; @injectable() export class ProvidedTaskConfigurations { @@ -30,13 +32,14 @@ export class ProvidedTaskConfigurations { @inject(TaskProviderRegistry) protected readonly taskProviderRegistry: TaskProviderRegistry; + @inject(TaskDefinitionRegistry) + protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + /** returns a list of provided tasks */ async getTasks(): Promise { - const providedTasks: TaskConfiguration[] = []; const providers = this.taskProviderRegistry.getProviders(); - for (const provider of providers) { - providedTasks.push(...await provider.provideTasks()); - } + const providedTasks: TaskConfiguration[] = (await Promise.all(providers.map(p => p.provideTasks()))) + .reduce((acc, taskArray) => acc.concat(taskArray), []); this.cacheTasks(providedTasks); return providedTasks; } @@ -52,6 +55,51 @@ export class ProvidedTaskConfigurations { } } + /** + * Finds the detected task for the given task customization. + * The detected task is considered as a "match" to the task customization if it has all the `required` properties. + * In case that more than one customization is found, return the one that has the biggest number of matched properties. + * + * @param customization the task customization + * @return the detected task for the given task customization. If the task customization is not found, `undefined` is returned. + */ + async getTaskToCustomize(customization: TaskCustomization, rootFolderPath: string): Promise { + const definition = this.taskDefinitionRegistry.getDefinition(customization); + if (!definition) { + return undefined; + } + + const matchedTasks: TaskConfiguration[] = []; + let highest = -1; + const tasks = await this.getTasks(); + for (const task of tasks) { // find detected tasks that match the `definition` + let score = 0; + if (!definition.properties.required.every(requiredProp => customization[requiredProp] !== undefined)) { + continue; + } + score += definition.properties.required.length; // number of required properties + const requiredProps = new Set(definition.properties.required); + // number of optional properties + score += definition.properties.all.filter(p => !requiredProps.has(p) && customization[p] !== undefined).length; + if (score >= highest) { + if (score > highest) { + highest = score; + matchedTasks.length = 0; + } + matchedTasks.push(task); + } + } + + // find the task that matches the `customization`. + // The scenario where more than one match is found should not happen unless users manually enter multiple customizations for one type of task + // If this does happen, return the first match + const rootFolderUri = new URI(rootFolderPath).toString(); + const matchedTask = matchedTasks.filter(t => + rootFolderUri === t._scope && definition.properties.all.every(p => t[p] === customization[p]) + )[0]; + return matchedTask; + } + protected getCachedTask(source: string, taskLabel: string): TaskConfiguration | undefined { const labelConfigMap = this.tasksMap.get(source); if (labelConfigMap) { diff --git a/packages/task/src/browser/quick-open-task.ts b/packages/task/src/browser/quick-open-task.ts index cd9c7ef6129c7..e5eae470680d8 100644 --- a/packages/task/src/browser/quick-open-task.ts +++ b/packages/task/src/browser/quick-open-task.ts @@ -20,7 +20,7 @@ import { QuickOpenGroupItem, QuickOpenMode, QuickOpenHandler, QuickOpenOptions, QuickOpenActionProvider, QuickOpenGroupItemOptions } from '@theia/core/lib/browser/quick-open/'; import { TaskService } from './task-service'; -import { TaskInfo, TaskConfiguration } from '../common/task-protocol'; +import { ContributedTaskConfiguration, TaskInfo, TaskConfiguration } from '../common/task-protocol'; import { TaskConfigurations } from './task-configurations'; import { TaskDefinitionRegistry } from './task-definition-registry'; import URI from '@theia/core/lib/common/uri'; @@ -66,7 +66,7 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { /** Initialize this quick open model with the tasks. */ async init(): Promise { const recentTasks = this.taskService.recentTasks; - const configuredTasks = this.taskService.getConfiguredTasks(); + const configuredTasks = await this.taskService.getConfiguredTasks(); const providedTasks = await this.taskService.getProvidedTasks(); const { filteredRecentTasks, filteredConfiguredTasks, filteredProvidedTasks } = this.getFilteredTasks(recentTasks, configuredTasks, providedTasks); @@ -213,7 +213,7 @@ export class QuickOpenTask implements QuickOpenModel, QuickOpenHandler { const filteredProvidedTasks: TaskConfiguration[] = []; providedTasks.forEach(provided => { - const exist = [...filteredRecentTasks, ...configuredTasks].some(t => TaskConfiguration.equals(provided, t)); + const exist = [...filteredRecentTasks, ...configuredTasks].some(t => ContributedTaskConfiguration.equals(provided, t)); if (!exist) { filteredProvidedTasks.push(provided); } diff --git a/packages/task/src/browser/task-configurations.ts b/packages/task/src/browser/task-configurations.ts index b45cf24311640..ebec9a66f6be2 100644 --- a/packages/task/src/browser/task-configurations.ts +++ b/packages/task/src/browser/task-configurations.ts @@ -15,8 +15,9 @@ ********************************************************************************/ import { inject, injectable } from 'inversify'; -import { TaskConfiguration, TaskCustomization, ContributedTaskConfiguration } from '../common'; +import { ContributedTaskConfiguration, TaskConfiguration, TaskCustomization, TaskDefinition } from '../common'; import { TaskDefinitionRegistry } from './task-definition-registry'; +import { ProvidedTaskConfigurations } from './provided-task-configurations'; import { Disposable, DisposableCollection, ResourceProvider } from '@theia/core/lib/common'; import URI from '@theia/core/lib/common/uri'; import { FileSystemWatcher, FileChangeEvent } from '@theia/filesystem/lib/browser/filesystem-watcher'; @@ -44,11 +45,14 @@ export class TaskConfigurations implements Disposable { protected readonly toDispose = new DisposableCollection(); /** - * Map of source (path of root folder that the task config comes from) and task config map. + * Map of source (path of root folder that the task configs come from) and task config map. * For the inner map (i.e., task config map), the key is task label and value TaskConfiguration */ protected tasksMap = new Map>(); - protected taskCustomizations: TaskCustomization[] = []; + /** + * Map of source (path of root folder that the task configs come from) and task customizations map. + */ + protected taskCustomizationMap = new Map(); protected watchedConfigFileUris: string[] = []; protected watchersMap = new Map(); // map of watchers for task config files, where the key is folder uri @@ -72,6 +76,9 @@ export class TaskConfigurations implements Disposable { @inject(TaskDefinitionRegistry) protected readonly taskDefinitionRegistry: TaskDefinitionRegistry; + @inject(ProvidedTaskConfigurations) + protected readonly providedTaskConfigurations: ProvidedTaskConfigurations; + constructor( @inject(FileSystemWatcher) protected readonly watcherServer: FileSystemWatcher, @inject(FileSystem) protected readonly fileSystem: FileSystem @@ -160,14 +167,28 @@ export class TaskConfigurations implements Disposable { return Array.from(this.tasksMap.values()).reduce((acc, labelConfigMap) => acc.concat(Array.from(labelConfigMap.keys())), [] as string[]); } - /** returns the list of known tasks */ - getTasks(): TaskConfiguration[] { - return Array.from(this.tasksMap.values()).reduce((acc, labelConfigMap) => acc.concat(Array.from(labelConfigMap.values())), [] as TaskConfiguration[]); + /** + * returns the list of known tasks, which includes: + * - all the configured tasks in `tasks.json`, and + * - the customized detected tasks + */ + async getTasks(): Promise { + const configuredTasks = Array.from(this.tasksMap.values()).reduce((acc, labelConfigMap) => acc.concat(Array.from(labelConfigMap.values())), [] as TaskConfiguration[]); + const detectedTasksAsConfigured: TaskConfiguration[] = []; + for (const [rootFolder, customizations] of Array.from(this.taskCustomizationMap.entries())) { + for (const cus of customizations) { + const detected = await this.providedTaskConfigurations.getTaskToCustomize(cus, rootFolder); + if (detected) { + detectedTasksAsConfigured.push(detected); + } + } + } + return [...configuredTasks, ...detectedTasksAsConfigured]; } /** returns the task configuration for a given label or undefined if none */ - getTask(source: string, taskLabel: string): TaskConfiguration | undefined { - const labelConfigMap = this.tasksMap.get(source); + getTask(rootFolderPath: string, taskLabel: string): TaskConfiguration | undefined { + const labelConfigMap = this.tasksMap.get(rootFolderPath); if (labelConfigMap) { return labelConfigMap.get(taskLabel); } @@ -179,8 +200,55 @@ export class TaskConfigurations implements Disposable { this.tasksMap.delete(source); } - getTaskCustomizations(type: string): TaskCustomization[] { - return this.taskCustomizations.filter(c => c.type === type); + /** + * Removes task customization objects found in the given task config file from the memory. + * Please note: this function does not modify the task config file. + */ + removeTaskCustomizations(configFileUri: string) { + const source = this.getSourceFolderFromConfigUri(configFileUri); + this.taskCustomizationMap.delete(source); + } + + /** + * Returns the task customizations by type from a given root folder in the workspace. + * @param type the type of task customizations + * @param rootFolder the root folder to find task customizations from. If `undefined`, this function returns an empty array. + */ + getTaskCustomizations(type: string, rootFolder?: string): TaskCustomization[] { + if (!rootFolder) { + return []; + } + + const customizationInRootFolder = this.taskCustomizationMap.get(new URI(rootFolder).path.toString()); + if (customizationInRootFolder) { + return customizationInRootFolder.filter(c => c.type === type); + } + return []; + } + + /** + * Returns the customization object in `tasks.json` for the given task. Please note, this function + * returns `undefined` if the given task is not a detected task, because configured tasks don't need + * customization objects - users can modify its config directly in `tasks.json`. + * @param taskConfig The task config, which could either be a configured task or a detected task. + */ + getCustomizationForTask(taskConfig: TaskConfiguration): TaskCustomization | undefined { + if (!this.isDetectedTask(taskConfig)) { + return undefined; + } + + const customizationByType = this.getTaskCustomizations(taskConfig.taskType || taskConfig.type, taskConfig._scope) || []; + const hasCustomization = customizationByType.length > 0; + if (hasCustomization) { + const taskDefinition = this.taskDefinitionRegistry.getDefinition(taskConfig); + if (taskDefinition) { + const cus = customizationByType.filter(customization => + taskDefinition.properties.required.every(rp => customization[rp] === taskConfig[rp]) + )[0]; // Only support having one customization per task + return cus; + } + } + return undefined; } /** returns the string uri of where the config file would be, if it existed under a given root directory */ @@ -226,19 +294,19 @@ export class TaskConfigurations implements Disposable { // user is editing the file in the auto-save mode, having momentarily // non-parsing JSON. this.removeTasks(configFileUri); + this.removeTaskCustomizations(configFileUri); + const rootFolderUri = this.getSourceFolderFromConfigUri(configFileUri); if (configuredTasksArray.length > 0) { const newTaskMap = new Map(); for (const task of configuredTasksArray) { newTaskMap.set(task.label, task); } - const source = this.getSourceFolderFromConfigUri(configFileUri); - this.tasksMap.set(source, newTaskMap); + this.tasksMap.set(rootFolderUri, newTaskMap); } if (customizations.length > 0) { - this.taskCustomizations.length = 0; - this.taskCustomizations = customizations; + this.taskCustomizationMap.set(rootFolderUri, customizations); } } } @@ -275,8 +343,21 @@ export class TaskConfigurations implements Disposable { return; } - const configFileUri = this.getConfigFileUri(workspace.uri); - if (!this.getTasks().some(t => t.label === task.label)) { + const isDetectedTask = this.isDetectedTask(task); + let sourceFolderUri: string | undefined; + if (isDetectedTask) { + sourceFolderUri = task._scope; + } else { + sourceFolderUri = task._source; + } + if (!sourceFolderUri) { + console.error('Global task cannot be customized'); + return; + } + + const configFileUri = this.getConfigFileUri(sourceFolderUri); + const configuredAndCustomizedTasks = await this.getTasks(); + if (!configuredAndCustomizedTasks.some(t => ContributedTaskConfiguration.equals(t, task))) { await this.saveTask(configFileUri, task); } @@ -287,6 +368,24 @@ export class TaskConfigurations implements Disposable { } } + private getTaskCustomizationTemplate(task: TaskConfiguration): TaskCustomization | undefined { + const definition = this.getTaskDefinition(task); + if (!definition) { + console.error('Detected / Contributed tasks should have a task definition.'); + return; + } + const customization: TaskCustomization = { type: task.taskType || task.type }; + definition.properties.all.forEach(p => { + if (task[p] !== undefined) { + customization[p] = task[p]; + } + }); + return { + ...customization, + problemMatcher: [] + }; + } + /** Writes the task to a config file. Creates a config file if this one does not exist */ async saveTask(configFileUri: string, task: TaskConfiguration): Promise { if (configFileUri && !await this.fileSystem.exists(configFileUri)) { @@ -294,12 +393,13 @@ export class TaskConfigurations implements Disposable { } const { _source, $ident, ...preparedTask } = task; + const customizedTaskTemplate = this.getTaskCustomizationTemplate(task) || preparedTask; try { const response = await this.fileSystem.resolveContent(configFileUri); const content = response.content; const formattingOptions = { tabSize: 4, insertSpaces: true, eol: '' }; - const edits = jsoncparser.modify(content, ['tasks', -1], preparedTask, { formattingOptions }); + const edits = jsoncparser.modify(content, ['tasks', -1], customizedTaskTemplate, { formattingOptions }); const result = jsoncparser.applyEdits(content, edits); const resource = await this.resourceProvider(new URI(configFileUri)); @@ -330,8 +430,15 @@ export class TaskConfigurations implements Disposable { /** checks if the config is a detected / contributed task */ private isDetectedTask(task: TaskConfiguration): task is ContributedTaskConfiguration { - const taskDefinition = this.taskDefinitionRegistry.getDefinition(task); + const taskDefinition = this.getTaskDefinition(task); // it is considered as a customization if the task definition registry finds a def for the task configuration return !!taskDefinition; } + + private getTaskDefinition(task: TaskConfiguration): TaskDefinition | undefined { + return this.taskDefinitionRegistry.getDefinition({ + ...task, + type: task.taskType || task.type + }); + } } diff --git a/packages/task/src/browser/task-definition-registry.ts b/packages/task/src/browser/task-definition-registry.ts index 771e916d4f340..303aad3d488b0 100644 --- a/packages/task/src/browser/task-definition-registry.ts +++ b/packages/task/src/browser/task-definition-registry.ts @@ -15,7 +15,7 @@ ********************************************************************************/ import { injectable } from 'inversify'; -import { TaskDefinition, TaskConfiguration } from '../common'; +import { TaskConfiguration, TaskCustomization, TaskDefinition } from '../common'; @injectable() export class TaskDefinitionRegistry { @@ -41,7 +41,7 @@ export class TaskDefinitionRegistry { * @param taskConfiguration the task configuration * @return the task definition for the task configuration. If the task definition is not found, `undefined` is returned. */ - getDefinition(taskConfiguration: TaskConfiguration): TaskDefinition | undefined { + getDefinition(taskConfiguration: TaskConfiguration | TaskCustomization): TaskDefinition | undefined { const definitions = this.getDefinitions(taskConfiguration.taskType || taskConfiguration.type); let matchedDefinition: TaskDefinition | undefined; let highest = -1; diff --git a/packages/task/src/browser/task-service.ts b/packages/task/src/browser/task-service.ts index ef152878a97e0..bcddca34d740f 100644 --- a/packages/task/src/browser/task-service.ts +++ b/packages/task/src/browser/task-service.ts @@ -27,14 +27,14 @@ import { ProblemManager } from '@theia/markers/lib/browser/problem/problem-manag import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { VariableResolverService } from '@theia/variable-resolver/lib/browser'; import { + ContributedTaskConfiguration, ProblemMatcher, ProblemMatchData, - ProblemMatcherContribution, + TaskCustomization, TaskServer, TaskExitedEvent, TaskInfo, TaskConfiguration, - TaskCustomization, TaskOutputProcessedEvent, RunTaskOption } from '../common'; @@ -203,13 +203,16 @@ export class TaskService implements TaskConfigurationClient { /** Returns an array of the task configurations configured in tasks.json and provided by the extensions. */ async getTasks(): Promise { - const configuredTasks = this.getConfiguredTasks(); + const configuredTasks = await this.getConfiguredTasks(); const providedTasks = await this.getProvidedTasks(); - return [...configuredTasks, ...providedTasks]; + const notCustomizedProvidedTasks = providedTasks.filter(provided => + !configuredTasks.some(configured => ContributedTaskConfiguration.equals(configured, provided)) + ); + return [...configuredTasks, ...notCustomizedProvidedTasks]; } /** Returns an array of the task configurations which are configured in tasks.json files */ - getConfiguredTasks(): TaskConfiguration[] { + getConfiguredTasks(): Promise { return this.taskConfigurations.getTasks(); } @@ -299,33 +302,34 @@ export class TaskService implements TaskConfigurationClient { /** * Runs a task, by the source and label of the task configuration. - * It looks for configured and provided tasks. + * It looks for configured and detected tasks. */ async run(source: string, taskLabel: string): Promise { let task = await this.getProvidedTask(source, taskLabel); - const matchers: (string | ProblemMatcherContribution)[] = []; - if (!task) { // if a provided task cannot be found, search from tasks.json + const customizationObject: TaskCustomization = { type: '' }; + if (!task) { // if a detected task cannot be found, search from tasks.json task = this.taskConfigurations.getTask(source, taskLabel); if (!task) { this.logger.error(`Can't get task launch configuration for label: ${taskLabel}`); return; } else if (task.problemMatcher) { - if (Array.isArray(task.problemMatcher)) { - matchers.push(...task.problemMatcher); - } else { - matchers.push(task.problemMatcher); - } + Object.assign(customizationObject, { + type: task.type, + problemMatcher: task.problemMatcher + }); + } + } else { // if a detected task is found, check if it is customized in tasks.json + const customizationFound = this.taskConfigurations.getCustomizationForTask(task); + if (customizationFound) { + Object.assign(customizationObject, customizationFound); } - } else { // if a provided task is found, check if it is customized in tasks.json - const taskType = task.taskType || task.type; - const customizations = this.taskConfigurations.getTaskCustomizations(taskType); - const matcherContributions = this.getProblemMatchers(task, customizations); - matchers.push(...matcherContributions); } await this.problemMatcherRegistry.onReady(); + const notResolvedMatchers = customizationObject.problemMatcher ? + (Array.isArray(customizationObject.problemMatcher) ? customizationObject.problemMatcher : [customizationObject.problemMatcher]) : []; const resolvedMatchers: ProblemMatcher[] = []; // resolve matchers before passing them to the server - for (const matcher of matchers) { + for (const matcher of notResolvedMatchers) { let resolvedMatcher: ProblemMatcher | undefined; if (typeof matcher === 'string') { resolvedMatcher = this.problemMatcherRegistry.get(matcher); @@ -343,7 +347,7 @@ export class TaskService implements TaskConfigurationClient { } } this.runTask(task, { - customization: { type: task.taskType || task.type, problemMatcher: resolvedMatchers } + customization: { ...customizationObject, ...{ problemMatcher: resolvedMatchers } } }); } @@ -383,27 +387,6 @@ export class TaskService implements TaskConfigurationClient { } } - private getProblemMatchers(taskConfiguration: TaskConfiguration, customizations: TaskCustomization[]): (string | ProblemMatcherContribution)[] { - const hasCustomization = customizations.length > 0; - const problemMatchers: (string | ProblemMatcherContribution)[] = []; - if (hasCustomization) { - const taskDefinition = this.taskDefinitionRegistry.getDefinition(taskConfiguration); - if (taskDefinition) { - const cus = customizations.filter(customization => - taskDefinition.properties.required.every(rp => customization[rp] === taskConfiguration[rp]) - )[0]; // Only support having one customization per task - if (cus && cus.problemMatcher) { - if (Array.isArray(cus.problemMatcher)) { - problemMatchers.push(...cus.problemMatcher); - } else { - problemMatchers.push(cus.problemMatcher); - } - } - } - } - return problemMatchers; - } - private async removeProblemMarks(option?: RunTaskOption): Promise { if (option && option.customization) { const matchersFromOption = option.customization.problemMatcher || []; diff --git a/packages/task/src/common/task-protocol.ts b/packages/task/src/common/task-protocol.ts index 1cb8aa322dfb0..294bee2332fdd 100644 --- a/packages/task/src/common/task-protocol.ts +++ b/packages/task/src/common/task-protocol.ts @@ -35,7 +35,8 @@ export interface TaskConfiguration extends TaskCustomization { } export namespace TaskConfiguration { export function equals(one: TaskConfiguration, other: TaskConfiguration): boolean { - return one.label === other.label && one._source === other._source; + return (one.taskType || one.type) === (other.taskType || other.type) && + one.label === other.label && one._source === other._source; } } @@ -52,6 +53,11 @@ export interface ContributedTaskConfiguration extends TaskConfiguration { */ readonly _scope: string | undefined; } +export namespace ContributedTaskConfiguration { + export function equals(one: TaskConfiguration, other: TaskConfiguration): boolean { + return TaskConfiguration.equals(one, other) && one._scope === other._scope; + } +} /** Runtime information about Task. */ export interface TaskInfo {