From 2704f526d83def3955087eb9477a1e3aa7e208ce Mon Sep 17 00:00:00 2001 From: Roman Nikitenko Date: Thu, 16 May 2019 17:38:23 +0300 Subject: [PATCH] Export tasks and launch configurations in config files of workspace folder Signed-off-by: Roman Nikitenko --- plugins/task-plugin/package.json | 3 +- .../src/che-task-backend-module.ts | 6 ++ .../src/export/export-configs-manager.ts | 82 ++++++++++++++ .../src/export/launch-configs-exporter.ts | 64 +++++++++++ .../src/export/task-configs-exporter.ts | 65 +++++++++++ .../task-plugin/src/machine/attach-client.ts | 2 +- .../src/machine/machine-exec-client.ts | 2 +- .../task-plugin/src/task-plugin-backend.ts | 4 + .../task-plugin/src/task/che-task-provider.ts | 7 +- plugins/task-plugin/src/uri-helper.ts | 35 ------ plugins/task-plugin/src/utils.ts | 102 ++++++++++++++++++ 11 files changed, 333 insertions(+), 39 deletions(-) create mode 100644 plugins/task-plugin/src/export/export-configs-manager.ts create mode 100644 plugins/task-plugin/src/export/launch-configs-exporter.ts create mode 100644 plugins/task-plugin/src/export/task-configs-exporter.ts delete mode 100644 plugins/task-plugin/src/uri-helper.ts create mode 100644 plugins/task-plugin/src/utils.ts diff --git a/plugins/task-plugin/package.json b/plugins/task-plugin/package.json index 5985312a9..198cd205a 100644 --- a/plugins/task-plugin/package.json +++ b/plugins/task-plugin/package.json @@ -64,6 +64,7 @@ "reflect-metadata": "0.1.8", "vscode-uri": "1.0.5", "vscode-ws-jsonrpc": "^0.0.2-1", - "ws": "^5.2.2" + "ws": "^5.2.2", + "jsonc-parser": "^2.0.2" } } diff --git a/plugins/task-plugin/src/che-task-backend-module.ts b/plugins/task-plugin/src/che-task-backend-module.ts index 7f5adb35f..d01b6d0d2 100644 --- a/plugins/task-plugin/src/che-task-backend-module.ts +++ b/plugins/task-plugin/src/che-task-backend-module.ts @@ -24,6 +24,9 @@ import { PreviewUrlsWidgetFactory, PreviewUrlsWidget, PreviewUrlsWidgetOptions } import { CheTaskPreviewMode } from './preview/task-preview-mode'; import { PreviewUrlOpenService } from './preview/preview-url-open-service'; import { CheWorkspaceClient } from './che-workspace-client'; +import { LaunchConfigurationsExporter } from './export/launch-configs-exporter'; +import { TaskConfigurationsExporter } from './export/task-configs-exporter'; +import { ConfigurationsExporter, ExportConfigurationsManager } from './export/export-configs-manager'; const container = new Container(); container.bind(CheTaskProvider).toSelf().inSingletonScope(); @@ -39,6 +42,9 @@ container.bind(ProjectPathVariableResolver).toSelf().inSingletonScope(); container.bind(CheWorkspaceClient).toSelf().inSingletonScope(); container.bind(CheTaskPreviewMode).toSelf().inSingletonScope(); container.bind(PreviewUrlOpenService).toSelf().inSingletonScope(); +container.bind(ConfigurationsExporter).to(TaskConfigurationsExporter).inSingletonScope(); +container.bind(ConfigurationsExporter).to(LaunchConfigurationsExporter).inSingletonScope(); +container.bind(ExportConfigurationsManager).toSelf().inSingletonScope(); container.bind(CheTerminalWidget).toSelf().inTransientScope(); container.bind(TerminalWidgetFactory).toDynamicValue(ctx => ({ diff --git a/plugins/task-plugin/src/export/export-configs-manager.ts b/plugins/task-plugin/src/export/export-configs-manager.ts new file mode 100644 index 000000000..f9efaf2bb --- /dev/null +++ b/plugins/task-plugin/src/export/export-configs-manager.ts @@ -0,0 +1,82 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { injectable, inject, multiInject } from 'inversify'; +import { CheWorkspaceClient } from '../che-workspace-client'; +import * as theia from '@theia/plugin'; +import { che as cheApi } from '@eclipse-che/api'; + +export const ConfigurationsExporter = Symbol('ConfigurationsExporter'); + +/** Exports content with configurations in the config file */ +export interface ConfigurationsExporter { + + /** Type of the exporter corresponds to type of command which brings content with configs */ + readonly type: string; + + /** + * Exports given content with configurations in the config file of given workspace folder + * @param configsContent content with configurations for export + * @param workspaceFolder workspace folder for exporting configs in the config file + */ + export(configsContent: string, workspaceFolder: theia.WorkspaceFolder): void; +} + +/** Reads the commands from the current Che workspace and exports task and launch configurations in the config files. */ +@injectable() +export class ExportConfigurationsManager { + + @inject(CheWorkspaceClient) + protected readonly cheWorkspaceClient: CheWorkspaceClient; + + @multiInject(ConfigurationsExporter) + protected readonly exporters: ConfigurationsExporter[]; + + async export() { + const workspaceFolders = theia.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length < 1) { + return; + } + + const cheCommands = await this.cheWorkspaceClient.getCommands(); + for (const exporter of this.exporters) { + const configsContent = this.extractConfigsContent(exporter.type, cheCommands); + if (!configsContent) { + continue; + } + + this.exportContent(configsContent, exporter, workspaceFolders); + } + } + + private exportContent(configsContent: string, exporter: ConfigurationsExporter, workspaceFolders: theia.WorkspaceFolder[]) { + for (const workspaceFolder of workspaceFolders) { + exporter.export(configsContent, workspaceFolder); + } + } + + private extractConfigsContent(type: string, commands: cheApi.workspace.Command[]): string { + const configCommands = commands.filter(command => command.type === type); + if (configCommands.length === 0) { + return ''; + } + + if (configCommands.length > 1) { + console.warn(`Found duplicate entry for type ${type}`); + } + + const configCommand = configCommands[0]; + if (!configCommand || !configCommand.attributes || !configCommand.attributes.actionReferenceContent) { + return ''; + } + + return configCommand.attributes.actionReferenceContent; + } +} diff --git a/plugins/task-plugin/src/export/launch-configs-exporter.ts b/plugins/task-plugin/src/export/launch-configs-exporter.ts new file mode 100644 index 000000000..caac93e81 --- /dev/null +++ b/plugins/task-plugin/src/export/launch-configs-exporter.ts @@ -0,0 +1,64 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { injectable } from 'inversify'; +import * as theia from '@theia/plugin'; +import { resolve } from 'path'; +import { readFileSync, writeFileSync, format, modify, parse } from '../utils'; +import { ConfigurationsExporter } from './export-configs-manager'; + +const CONFIG_DIR = '.theia'; +const LAUNCH_CONFIG_FILE = 'launch.json'; +const formattingOptions = { tabSize: 4, insertSpaces: true, eol: '' }; + +export const VSCODE_LAUNCH_TYPE = 'vscode-launch'; + +/** Exports content with launch configurations in the config file. */ +@injectable() +export class LaunchConfigurationsExporter implements ConfigurationsExporter { + readonly type: string = VSCODE_LAUNCH_TYPE; + + export(configsContent: string, workspaceFolder: theia.WorkspaceFolder): void { + const launchConfigFileUri = this.getConfigFileUri(workspaceFolder.uri.path); + const existingContent = readFileSync(launchConfigFileUri); + if (configsContent === existingContent) { + return; + } + + const configsJson = parse(configsContent); + if (!configsJson || !configsJson.configurations) { + return; + } + + const existingJson = parse(existingContent); + if (!existingJson || !existingJson.configurations) { + writeFileSync(launchConfigFileUri, format(configsContent, formattingOptions)); + return; + } + + const mergedConfigs = this.merge(existingJson.configurations, configsJson.configurations); + const result = modify(configsContent, ['configurations'], mergedConfigs, formattingOptions); + writeFileSync(launchConfigFileUri, result); + } + + private merge(existingConfigs: theia.DebugConfiguration[], newConfigs: theia.DebugConfiguration[]): theia.DebugConfiguration[] { + const result: theia.DebugConfiguration[] = Object.assign([], newConfigs); + for (const existing of existingConfigs) { + if (!newConfigs.some(config => config.name === existing.name)) { + result.push(existing); + } + } + return result; + } + + private getConfigFileUri(rootDir: string): string { + return resolve(rootDir.toString(), CONFIG_DIR, LAUNCH_CONFIG_FILE); + } +} diff --git a/plugins/task-plugin/src/export/task-configs-exporter.ts b/plugins/task-plugin/src/export/task-configs-exporter.ts new file mode 100644 index 000000000..55891d187 --- /dev/null +++ b/plugins/task-plugin/src/export/task-configs-exporter.ts @@ -0,0 +1,65 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { injectable } from 'inversify'; +import * as theia from '@theia/plugin'; +import { TaskConfiguration } from '@eclipse-che/plugin'; +import { resolve } from 'path'; +import { readFileSync, writeFileSync, format, modify, parse } from '../utils'; +import { ConfigurationsExporter } from './export-configs-manager'; + +const CONFIG_DIR = '.theia'; +const TASK_CONFIG_FILE = 'tasks.json'; +const formattingOptions = { tabSize: 4, insertSpaces: true, eol: '' }; + +export const VSCODE_TASK_TYPE = 'vscode-task'; + +/** Exports configurations of tasks in the config file. */ +@injectable() +export class TaskConfigurationsExporter implements ConfigurationsExporter { + readonly type: string = VSCODE_TASK_TYPE; + + export(tasksContent: string, workspaceFolder: theia.WorkspaceFolder): void { + const tasksConfigFileUri = this.getConfigFileUri(workspaceFolder.uri.path); + const existingContent = readFileSync(tasksConfigFileUri); + if (tasksContent === existingContent) { + return; + } + + const tasksJson = parse(tasksContent); + if (!tasksJson || !tasksJson.tasks) { + return; + } + + const existingJson = parse(existingContent); + if (!existingJson || !existingJson.tasks) { + writeFileSync(tasksConfigFileUri, format(tasksContent, formattingOptions)); + return; + } + + const mergedConfigs = this.merge(existingJson.tasks, tasksJson.tasks); + const result = modify(tasksContent, ['tasks'], mergedConfigs, formattingOptions); + writeFileSync(tasksConfigFileUri, result); + } + + private merge(existingConfigs: TaskConfiguration[], newConfigs: TaskConfiguration[]): TaskConfiguration[] { + const result: TaskConfiguration[] = Object.assign([], newConfigs); + for (const existing of existingConfigs) { + if (!newConfigs.some(config => config.label === existing.label)) { + result.push(existing); + } + } + return result; + } + + private getConfigFileUri(rootDir: string): string { + return resolve(rootDir.toString(), CONFIG_DIR, TASK_CONFIG_FILE); + } +} diff --git a/plugins/task-plugin/src/machine/attach-client.ts b/plugins/task-plugin/src/machine/attach-client.ts index 34ac95c77..944cf991f 100644 --- a/plugins/task-plugin/src/machine/attach-client.ts +++ b/plugins/task-plugin/src/machine/attach-client.ts @@ -11,7 +11,7 @@ import { injectable, inject } from 'inversify'; import { CheWorkspaceClient } from '../che-workspace-client'; import { ReconnectingWebSocket } from './websocket'; -import { applySegmentsToUri } from '../uri-helper'; +import { applySegmentsToUri } from '../utils'; import { MachineExecWatcher } from './machine-exec-watcher'; import * as startPoint from '../task-plugin-backend'; diff --git a/plugins/task-plugin/src/machine/machine-exec-client.ts b/plugins/task-plugin/src/machine/machine-exec-client.ts index 1c0579286..ec67dd988 100644 --- a/plugins/task-plugin/src/machine/machine-exec-client.ts +++ b/plugins/task-plugin/src/machine/machine-exec-client.ts @@ -12,7 +12,7 @@ import * as rpc from 'vscode-ws-jsonrpc'; import { injectable, inject, postConstruct } from 'inversify'; import { CheWorkspaceClient } from '../che-workspace-client'; import { createConnection } from './websocket'; -import { applySegmentsToUri } from '../uri-helper'; +import { applySegmentsToUri } from '../utils'; import { MachineExecWatcher } from './machine-exec-watcher'; const CREATE_METHOD_NAME: string = 'create'; diff --git a/plugins/task-plugin/src/task-plugin-backend.ts b/plugins/task-plugin/src/task-plugin-backend.ts index 84190f761..ed052b9b1 100644 --- a/plugins/task-plugin/src/task-plugin-backend.ts +++ b/plugins/task-plugin/src/task-plugin-backend.ts @@ -19,6 +19,7 @@ import { ServerVariableResolver } from './variable/server-variable-resolver'; import { ProjectPathVariableResolver } from './variable/project-path-variable-resolver'; import { CheTaskEventsHandler } from './preview/task-events-handler'; import { TasksPreviewManager } from './preview/tasks-preview-manager'; +import { ExportConfigurationsManager } from './export/export-configs-manager'; let pluginContext: theia.PluginContext; @@ -44,6 +45,9 @@ export async function start(context: theia.PluginContext) { const cheTaskRunner = container.get(CheTaskRunner); const taskRunnerSubscription = await che.task.registerTaskRunner(CHE_TASK_TYPE, cheTaskRunner); getSubscriptions().push(taskRunnerSubscription); + + const exportConfigurationsManager = container.get(ExportConfigurationsManager); + exportConfigurationsManager.export(); } export function stop() { } diff --git a/plugins/task-plugin/src/task/che-task-provider.ts b/plugins/task-plugin/src/task/che-task-provider.ts index 5ebe6a1de..4276af215 100644 --- a/plugins/task-plugin/src/task/che-task-provider.ts +++ b/plugins/task-plugin/src/task/che-task-provider.ts @@ -15,6 +15,8 @@ import { Task } from '@theia/plugin'; import { CHE_TASK_TYPE, MACHINE_NAME_ATTRIBUTE, PREVIEW_URL_ATTRIBUTE, WORKING_DIR_ATTRIBUTE, CheTaskDefinition, Target } from './task-protocol'; import { MachinesPicker } from '../machine/machines-picker'; import { CheWorkspaceClient } from '../che-workspace-client'; +import { VSCODE_LAUNCH_TYPE } from '../export/launch-configs-exporter'; +import { VSCODE_TASK_TYPE } from '../export/task-configs-exporter'; /** Reads the commands from the current Che workspace and provides it as Task Configurations. */ @injectable() @@ -27,7 +29,10 @@ export class CheTaskProvider { async provideTasks(): Promise { const commands = await this.cheWorkspaceClient.getCommands(); - return commands.map(command => this.toTask(command)); + const filteredCommands = commands.filter(command => + command.type !== VSCODE_TASK_TYPE && + command.type !== VSCODE_LAUNCH_TYPE); + return filteredCommands.map(command => this.toTask(command)); } async resolveTask(task: Task): Promise { diff --git a/plugins/task-plugin/src/uri-helper.ts b/plugins/task-plugin/src/uri-helper.ts deleted file mode 100644 index 3aafae76d..000000000 --- a/plugins/task-plugin/src/uri-helper.ts +++ /dev/null @@ -1,35 +0,0 @@ -/********************************************************************* - * Copyright (c) 2019 Red Hat, Inc. - * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - **********************************************************************/ - -import { URL } from 'url'; -import { resolve } from 'path'; - -/** - * Apply segments to the url endpoint, where are: - * @param endPointUrl - url endpoint, for example 'http://ws:/some-server/api' - * @param pathSegements - array path segements, which should be applied one by one to the url. - * Example: - * applySegmentsToUri('http://ws:/some-server/api', 'connect', `1`)) => http://ws/some-server/api/connect/1 - * applySegmentsToUri('http://ws:/some-server/api/', 'connect', `1`)) => http://ws/some-server/api/connect/1 - * applySegmentsToUri('http://ws:/some-server/api//', 'connect', `1`)) => http://ws/some-server/api/connect/1 - * applySegmentsToUri('http://ws:/some-server/api', '/connect', `1`)) => error, segment should not contains '/' - */ -export function applySegmentsToUri(endPointUrl: string, ...pathSegements: string[]): string { - const urlToTransform: URL = new URL(endPointUrl); - - for (const segment of pathSegements) { - if (segment.indexOf('/') > -1) { - throw new Error(`path segment ${segment} contains '/'`); - } - urlToTransform.pathname = resolve(urlToTransform.pathname, segment); - } - - return urlToTransform.toString(); -} diff --git a/plugins/task-plugin/src/utils.ts b/plugins/task-plugin/src/utils.ts new file mode 100644 index 000000000..5164aa8ee --- /dev/null +++ b/plugins/task-plugin/src/utils.ts @@ -0,0 +1,102 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ + +import { URL } from 'url'; +import * as path from 'path'; +import { resolve } from 'path'; +import * as jsoncparser from 'jsonc-parser'; +import { FormattingOptions, ParseError, JSONPath } from 'jsonc-parser'; + +const fs = require('fs'); + +/** + * Apply segments to the url endpoint, where are: + * @param endPointUrl - url endpoint, for example 'http://ws:/some-server/api' + * @param pathSegements - array path segements, which should be applied one by one to the url. + * Example: + * applySegmentsToUri('http://ws:/some-server/api', 'connect', `1`)) => http://ws/some-server/api/connect/1 + * applySegmentsToUri('http://ws:/some-server/api/', 'connect', `1`)) => http://ws/some-server/api/connect/1 + * applySegmentsToUri('http://ws:/some-server/api//', 'connect', `1`)) => http://ws/some-server/api/connect/1 + * applySegmentsToUri('http://ws:/some-server/api', '/connect', `1`)) => error, segment should not contains '/' + */ +export function applySegmentsToUri(endPointUrl: string, ...pathSegements: string[]): string { + const urlToTransform: URL = new URL(endPointUrl); + + for (const segment of pathSegements) { + if (segment.indexOf('/') > -1) { + throw new Error(`path segment ${segment} contains '/'`); + } + urlToTransform.pathname = resolve(urlToTransform.pathname, segment); + } + + return urlToTransform.toString(); +} + +/** Parses the given content and returns the object the JSON content represents. */ +// tslint:disable-next-line:no-any +export function parse(content: string): any { + const strippedContent = jsoncparser.stripComments(content); + const errors: ParseError[] = []; + const configurations = jsoncparser.parse(strippedContent, errors); + + if (errors.length) { + for (const e of errors) { + console.error(`Error parsing configurations: error: ${e.error}, length: ${e.length}, offset: ${e.offset}`); + } + return ''; + } else { + return configurations; + } +} + +/** Formats content according to given formatting options */ +export function format(content: string, options: FormattingOptions): string { + const edits = jsoncparser.format(content, undefined, options); + return jsoncparser.applyEdits(content, edits); +} + +/** + * Modifies JSON document using json path, value and options. + * + * @param content JSON document for changes + * @param jsonPath path of the value to change - the document root, a property or an array item. + * @param value new value for the specified property or item. + * @param options options to apply formatting + */ +// tslint:disable-next-line:no-any +export function modify(content: string, jsonPath: JSONPath, value: any, options: FormattingOptions): string { + const edits = jsoncparser.modify(content, jsonPath, value, { formattingOptions: options }); + return jsoncparser.applyEdits(content, edits); +} + +/** Synchronously reads the file by given path. Returns content of the file or empty string if file doesn't exist */ +export function readFileSync(filePath: string): string { + try { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; + } catch (e) { + console.error(e); + return ''; + } +} + +/** Synchronously writes given content to the file. Creates directories to the file if they don't exist */ +export function writeFileSync(filePath: string, content: string): void { + ensureDirExistence(filePath); + fs.writeFileSync(filePath, content); +} + +/** Synchronously creates a directory to the file if they don't exist */ +export function ensureDirExistence(filePath: string) { + const dirName = path.dirname(filePath); + if (fs.existsSync(dirName)) { + return; + } + fs.mkdirSync(dirName, { recursive: true }); +}