From fe224bf04e371f054c7fa059bfc5ba9da99b8e07 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Thu, 11 Aug 2022 10:38:20 +0200 Subject: [PATCH] Add support for compound launches (#11444) Extend debug model for compound launches - Support pre-launch task, stopAll, list of configurations - Properly parse compounds into configuration model - Support mapping to custom session option type - Support search of options by configuration, compound, and name Show compound launches in configuration dropdown - Query all available options - Use JSON (de-)serialization to allow multiple options with same name -- Previously custom string was used for identification - Extend session manager to start compound session options - Ensure compound sessions can also be found from DebugMain Minor fixes: - Prefer select options rendering to bottom in most cases - Using 'Add Configurations' in dropdown triggers focus-lost on pick service automatically closing the workspace selection, keep picking open Fixes https://github.com/eclipse-theia/theia/issues/11302 --- .../src/browser/widgets/select-component.tsx | 32 ++--- .../browser/debug-configuration-manager.ts | 132 ++++++++++++------ .../src/browser/debug-configuration-model.ts | 29 ++-- ...debug-frontend-application-contribution.ts | 13 +- .../src/browser/debug-prefix-configuration.ts | 6 +- .../debug/src/browser/debug-schema-updater.ts | 61 +++++++- .../src/browser/debug-session-contribution.ts | 4 +- .../src/browser/debug-session-manager.ts | 87 ++++++++++-- .../src/browser/debug-session-options.ts | 87 ++++++++++-- packages/debug/src/browser/debug-session.tsx | 10 +- .../view/debug-configuration-select.tsx | 62 +++++--- packages/debug/src/common/debug-compound.ts | 32 +++++ .../browser/hosted-plugin-manager-client.ts | 4 +- .../src/main/browser/debug/debug-main.ts | 30 ++-- .../debug/plugin-debug-session-factory.ts | 6 +- 15 files changed, 453 insertions(+), 142 deletions(-) create mode 100644 packages/debug/src/common/debug-compound.ts diff --git a/packages/core/src/browser/widgets/select-component.tsx b/packages/core/src/browser/widgets/select-component.tsx index 9075bbde425aa..821413ba4900d 100644 --- a/packages/core/src/browser/widgets/select-component.tsx +++ b/packages/core/src/browser/widgets/select-component.tsx @@ -30,6 +30,7 @@ export interface SelectOption { detail?: string description?: string markdown?: boolean + userData?: string } export interface SelectComponentProps { @@ -40,15 +41,8 @@ export interface SelectComponentProps { onFocus?: () => void } -export interface SelectComponentDropdownDimensions { - top: number - left: number - width: number - parentHeight: number -}; - export interface SelectComponentState { - dimensions?: SelectComponentDropdownDimensions + dimensions?: DOMRect selected: number original: number hover: number @@ -86,6 +80,10 @@ export class SelectComponent extends React.Component clientRect.height - this.state.dimensions.top; + const availableTop = this.state.dimensions.top - clientRect.top; + const availableBottom = clientRect.top + clientRect.height - this.state.dimensions.bottom; + // prefer rendering to the bottom unless there is not enough space and more content can be shown to the top + const invert = availableBottom < this.optimalHeight && (availableBottom - this.optimalHeight) < (availableTop - this.optimalHeight); const { options } = this.props; const { hover } = this.state; const description = options[hover].description; @@ -319,8 +313,8 @@ export class SelectComponent extends React.Component; - protected recentDynamicOptionsTracker: DebugSessionOptions[] = []; + protected recentDynamicOptionsTracker: DynamicDebugConfigurationSessionOptions[] = []; @postConstruct() protected async init(): Promise { @@ -141,10 +142,10 @@ export class DebugConfigurationManager { protected *getAll(): IterableIterator { for (const model of this.models.values()) { for (const configuration of model.configurations) { - yield { - configuration, - workspaceFolderUri: model.workspaceFolderUri - }; + yield this.configurationToOptions(configuration, model.workspaceFolderUri); + } + for (const compound of model.compounds) { + yield this.compoundToOptions(compound, model.workspaceFolderUri); } } } @@ -159,7 +160,7 @@ export class DebugConfigurationManager { } protected *doGetSupported(debugTypes: Set): IterableIterator { for (const options of this.getAll()) { - if (debugTypes.has(options.configuration.type)) { + if (options.configuration && debugTypes.has(options.configuration.type)) { yield options; } } @@ -171,8 +172,7 @@ export class DebugConfigurationManager { } async getSelectedConfiguration(): Promise { - // providerType applies to dynamic configurations only - if (!this._currentOptions?.providerType) { + if (!DebugSessionOptions.isDynamic(this._currentOptions)) { return this._currentOptions; } @@ -188,7 +188,7 @@ export class DebugConfigurationManager { throw new Error(message); } - return { configuration, providerType }; + return { name, configuration, providerType }; } set current(option: DebugSessionOptions | undefined) { @@ -197,7 +197,7 @@ export class DebugConfigurationManager { } protected updateRecentlyUsedDynamicConfigurationOptions(option: DebugSessionOptions | undefined): void { - if (option?.providerType) { // if it's a dynamic configuration option + if (DebugSessionOptions.isDynamic(option)) { // Removing an item already present in the list const index = this.recentDynamicOptionsTracker.findIndex(item => this.dynamicOptionsMatch(item, option)); if (index > -1) { @@ -212,32 +212,33 @@ export class DebugConfigurationManager { } } - protected dynamicOptionsMatch(one: DebugSessionOptions, other: DebugSessionOptions): boolean { + protected dynamicOptionsMatch(one: DynamicDebugConfigurationSessionOptions, other: DynamicDebugConfigurationSessionOptions): boolean { return one.providerType !== undefined - && one.configuration.name === other.configuration.name - && one.providerType === other.providerType; + && one.configuration.name === other.configuration.name + && one.providerType === other.providerType; } - get recentDynamicOptions(): readonly DebugSessionOptions[] { + get recentDynamicOptions(): readonly DynamicDebugConfigurationSessionOptions[] { return this.recentDynamicOptionsTracker; } protected updateCurrent(options: DebugSessionOptions | undefined = this._currentOptions): void { - this._currentOptions = options && this.find(options.configuration, options.workspaceFolderUri, options.providerType); + if (DebugSessionOptions.isCompound(options)) { + this._currentOptions = options && this.find(options.compound, options.workspaceFolderUri); + } else { + this._currentOptions = options && this.find(options.configuration, options.workspaceFolderUri, options.providerType); + } if (!this._currentOptions) { const model = this.getModel(); if (model) { const configuration = model.configurations[0]; if (configuration) { - this._currentOptions = { - configuration, - workspaceFolderUri: model.workspaceFolderUri - }; + this._currentOptions = this.configurationToOptions(configuration, model.workspaceFolderUri); } } } - this.debugConfigurationTypeKey.set(this.current && this.current.configuration.type); + this.debugConfigurationTypeKey.set(this.current && this.current.configuration?.type); this.onDidChangeEmitter.fire(undefined); } @@ -248,24 +249,57 @@ export class DebugConfigurationManager { /** * Find / Resolve DebugSessionOptions from a given target debug configuration */ - find(targetConfiguration: DebugConfiguration, workspaceFolderUri?: string, providerType?: string): DebugSessionOptions | undefined; - find(nameOrTargetConfiguration: string | DebugConfiguration, workspaceFolderUri?: string, providerType?: string): DebugSessionOptions | undefined { - // providerType is only applicable to dynamic debug configurations - if (typeof nameOrTargetConfiguration === 'object' && providerType) { - return { - configuration: nameOrTargetConfiguration, - providerType - }; - } - const name = typeof nameOrTargetConfiguration === 'string' ? nameOrTargetConfiguration : nameOrTargetConfiguration.name; + find(compound: DebugCompound, workspaceFolderUri?: string): DebugSessionOptions | undefined; + find(configuration: DebugConfiguration, workspaceFolderUri?: string, providerType?: string): DebugSessionOptions | undefined; + find(name: string, workspaceFolderUri?: string, providerType?: string): DebugSessionOptions | undefined; + find(nameOrConfigurationOrCompound: string | DebugConfiguration | DebugCompound, workspaceFolderUri?: string, providerType?: string): DebugSessionOptions | undefined { + if (DebugConfiguration.is(nameOrConfigurationOrCompound) && providerType) { + // providerType is only applicable to dynamic debug configurations and may only be created if we have a configuration given + return this.configurationToOptions(nameOrConfigurationOrCompound, workspaceFolderUri, providerType); + } + const name = typeof nameOrConfigurationOrCompound === 'string' ? nameOrConfigurationOrCompound : nameOrConfigurationOrCompound.name; + const configuration = this.findConfiguration(name, workspaceFolderUri); + if (configuration) { + return this.configurationToOptions(configuration, workspaceFolderUri); + } + const compound = this.findCompound(name, workspaceFolderUri); + if (compound) { + return this.compoundToOptions(compound, workspaceFolderUri); + } + } + + findConfigurations(name: string, workspaceFolderUri?: string): DebugConfiguration[] { + const matches = []; for (const model of this.models.values()) { if (model.workspaceFolderUri === workspaceFolderUri) { for (const configuration of model.configurations) { if (configuration.name === name) { - return { - configuration, - workspaceFolderUri - }; + matches.push(configuration); + } + } + } + } + return matches; + } + + findConfiguration(name: string, workspaceFolderUri?: string): DebugConfiguration | undefined { + for (const model of this.models.values()) { + if (model.workspaceFolderUri === workspaceFolderUri) { + for (const configuration of model.configurations) { + if (configuration.name === name) { + return configuration; + } + } + } + } + } + + findCompound(name: string, workspaceFolderUri?: string): DebugCompound | undefined { + for (const model of this.models.values()) { + if (model.workspaceFolderUri === workspaceFolderUri) { + for (const compound of model.compounds) { + if (compound.name === name) { + return compound; } } } @@ -279,6 +313,14 @@ export class DebugConfigurationManager { } } + protected configurationToOptions(configuration: DebugConfiguration, workspaceFolderUri?: string, providerType?: string): DebugSessionOptions { + return { name: configuration.name, configuration, providerType, workspaceFolderUri }; + } + + protected compoundToOptions(compound: DebugCompound, workspaceFolderUri?: string): DebugSessionOptions { + return { name: compound.name, compound, workspaceFolderUri }; + } + async addConfiguration(): Promise { let rootUri: URI | undefined = undefined; if (this.workspaceService.saved && this.workspaceService.tryGetRoots().length > 1) { @@ -345,7 +387,8 @@ export class DebugConfigurationManager { }); } const root = await this.quickPickService.show(items, { - placeholder: nls.localize('theia/debug/addConfigurationPlaceholder', 'Select workspace root to add configuration to') + placeholder: nls.localize('theia/debug/addConfigurationPlaceholder', 'Select workspace root to add configuration to'), + ignoreFocusOut: true }); return root?.value; } @@ -469,17 +512,26 @@ export class DebugConfigurationManager { // Between versions v1.26 and v1.27, the expected format of the data changed so that old stored data // may not contain the configuration key. - if (data.current && 'configuration' in data.current) { + if (DebugSessionOptions.isConfiguration(data.current)) { + // ensure options name is reflected from old configurations data + data.current.name = data.current.name ?? data.current.configuration?.name; this.current = this.find(data.current.configuration, data.current.workspaceFolderUri, data.current.providerType); + } else if (DebugSessionOptions.isCompound(data.current)) { + this.current = this.find(data.current.name, data.current.workspaceFolderUri); } } - protected resolveRecentDynamicOptionsFromData(options?: DebugSessionOptions[]): void { + protected resolveRecentDynamicOptionsFromData(options?: DynamicDebugConfigurationSessionOptions[]): void { if (!options || this.recentDynamicOptionsTracker.length !== 0) { return; } - this.recentDynamicOptionsTracker = options; + // ensure options name is reflected from old configurations data + const dynamicOptions = options.map(option => { + option.name = option.name ?? option.configuration.name; + return option; + }).filter(DebugSessionOptions.isDynamic); + this.recentDynamicOptionsTracker = dynamicOptions; } save(): void { @@ -502,6 +554,6 @@ export class DebugConfigurationManager { export namespace DebugConfigurationManager { export interface Data { current?: DebugSessionOptions, - recentDynamicOptions?: DebugSessionOptions[] + recentDynamicOptions?: DynamicDebugConfigurationSessionOptions[] } } diff --git a/packages/debug/src/browser/debug-configuration-model.ts b/packages/debug/src/browser/debug-configuration-model.ts index bdde6f3f51cef..90260b3aedf6f 100644 --- a/packages/debug/src/browser/debug-configuration-model.ts +++ b/packages/debug/src/browser/debug-configuration-model.ts @@ -19,6 +19,7 @@ import { Emitter, Event } from '@theia/core/lib/common/event'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { DebugConfiguration } from '../common/debug-common'; import { PreferenceService } from '@theia/core/lib/browser/preferences/preference-service'; +import { DebugCompound } from '../common/debug-compound'; export class DebugConfigurationModel implements Disposable { @@ -58,6 +59,10 @@ export class DebugConfigurationModel implements Disposable { return this.json.configurations; } + get compounds(): DebugCompound[] { + return this.json.compounds; + } + async reconcile(): Promise { this.json = this.parseConfigurations(); this.onDidChangeEmitter.fire(undefined); @@ -66,19 +71,22 @@ export class DebugConfigurationModel implements Disposable { const configurations: DebugConfiguration[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { configUri, value } = this.preferences.resolve('launch', undefined, this.workspaceFolderUri); - if (value && typeof value === 'object' && 'configurations' in value) { - if (Array.isArray(value.configurations)) { - for (const configuration of value.configurations) { - if (DebugConfiguration.is(configuration)) { - configurations.push(configuration); - } + if (value && typeof value === 'object' && Array.isArray(value.configurations)) { + for (const configuration of value.configurations) { + if (DebugConfiguration.is(configuration)) { + configurations.push(configuration); + } + } + } + const compounds: DebugCompound[] = []; + if (value && typeof value === 'object' && Array.isArray(value.compounds)) { + for (const compound of value.compounds) { + if (DebugCompound.is(compound)) { + compounds.push(compound); } } } - return { - uri: configUri, - configurations - }; + return { uri: configUri, configurations, compounds }; } } @@ -86,5 +94,6 @@ export namespace DebugConfigurationModel { export interface JsonContent { uri?: URI configurations: DebugConfiguration[] + compounds: DebugCompound[] } } diff --git a/packages/debug/src/browser/debug-frontend-application-contribution.ts b/packages/debug/src/browser/debug-frontend-application-contribution.ts index e05801970c70c..c423322445897 100644 --- a/packages/debug/src/browser/debug-frontend-application-contribution.ts +++ b/packages/debug/src/browser/debug-frontend-application-contribution.ts @@ -1122,8 +1122,9 @@ export class DebugFrontendApplicationContribution extends AbstractViewContributi await this.configurations.addConfiguration(); return; } - if (current) { - if (noDebug !== undefined) { + + if (noDebug !== undefined) { + if (current.configuration) { current = { ...current, configuration: { @@ -1131,9 +1132,15 @@ export class DebugFrontendApplicationContribution extends AbstractViewContributi noDebug } }; + } else { + current = { + ...current, + noDebug + }; } - await this.manager.start(current); } + + await this.manager.start(current); } get threads(): DebugThreadsWidget | undefined { diff --git a/packages/debug/src/browser/debug-prefix-configuration.ts b/packages/debug/src/browser/debug-prefix-configuration.ts index 5ead072ff287a..70bbcc6330f2e 100644 --- a/packages/debug/src/browser/debug-prefix-configuration.ts +++ b/packages/debug/src/browser/debug-prefix-configuration.ts @@ -113,7 +113,7 @@ export class DebugPrefixConfiguration implements CommandContribution, CommandHan for (const config of configurations) { items.push({ - label: config.configuration.name, + label: config.name, description: this.workspaceService.isMultiRootWorkspaceOpened ? this.labelProvider.getName(new URI(config.workspaceFolderUri)) : '', @@ -134,7 +134,7 @@ export class DebugPrefixConfiguration implements CommandContribution, CommandHan for (const configuration of dynamicConfigurations) { items.push({ label: configuration.name, - execute: () => this.runConfiguration({ configuration, providerType }) + execute: () => this.runConfiguration({ name: configuration.name, configuration, providerType }) }); } } @@ -170,7 +170,7 @@ export class DebugPrefixConfiguration implements CommandContribution, CommandHan */ protected updateStatusBar(): void { const text: string = this.debugConfigurationManager.current - ? this.debugConfigurationManager.current.configuration.name + ? this.debugConfigurationManager.current.name : ''; const icon = '$(codicon-debug-alt-small)'; this.statusBar.setElement(this.statusBarId, { diff --git a/packages/debug/src/browser/debug-schema-updater.ts b/packages/debug/src/browser/debug-schema-updater.ts index e15bb867a0214..65aff2a5e7c6b 100644 --- a/packages/debug/src/browser/debug-schema-updater.ts +++ b/packages/debug/src/browser/debug-schema-updater.ts @@ -16,13 +16,14 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { JsonSchemaRegisterContext, JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-store'; -import { InMemoryResources, deepClone } from '@theia/core/lib/common'; +import { InMemoryResources, deepClone, nls } from '@theia/core/lib/common'; import { IJSONSchema } from '@theia/core/lib/common/json-schema'; 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'; +import { defaultCompound } from '../common/debug-compound'; @injectable() export class DebugSchemaUpdater implements JsonSchemaContribution { @@ -73,24 +74,74 @@ export const launchSchemaId = 'vscode://schemas/launch'; const launchSchema: IJSONSchema = { $id: launchSchemaId, type: 'object', - title: 'Launch', + title: nls.localizeByDefault('Launch'), required: [], - default: { version: '0.2.0', configurations: [] }, + default: { version: '0.2.0', configurations: [], compounds: [] }, properties: { version: { type: 'string', - description: 'Version of this file format.', + description: nls.localizeByDefault('Version of this file format.'), default: '0.2.0' }, configurations: { type: 'array', - description: 'List of configurations. Add new configurations or edit existing ones by using IntelliSense.', + description: nls.localizeByDefault('List of configurations. Add new configurations or edit existing ones by using IntelliSense.'), items: { defaultSnippets: [], 'type': 'object', oneOf: [] } }, + compounds: { + type: 'array', + description: nls.localizeByDefault('List of compounds. Each compound references multiple configurations which will get launched together.'), + items: { + type: 'object', + required: ['name', 'configurations'], + properties: { + name: { + type: 'string', + description: nls.localizeByDefault('Name of compound. Appears in the launch configuration drop down menu.') + }, + configurations: { + type: 'array', + default: [], + items: { + oneOf: [{ + type: 'string', + description: nls.localizeByDefault('Please use unique configuration names.') + }, { + type: 'object', + required: ['name'], + properties: { + name: { + enum: [], + description: nls.localizeByDefault('Name of compound. Appears in the launch configuration drop down menu.') + }, + folder: { + enum: [], + description: nls.localizeByDefault('Name of folder in which the compound is located.') + } + } + }] + }, + description: nls.localizeByDefault('Names of configurations that will be started as part of this compound.') + }, + stopAll: { + type: 'boolean', + default: false, + description: nls.localizeByDefault('Controls whether manually terminating one session will stop all of the compound sessions.') + }, + preLaunchTask: { + type: 'string', + default: '', + description: nls.localizeByDefault('Task to run before any of the compound configurations start.') + } + }, + default: defaultCompound + }, + default: [defaultCompound] + }, inputs: inputsSchema.definitions!.inputs }, allowComments: true, diff --git a/packages/debug/src/browser/debug-session-contribution.ts b/packages/debug/src/browser/debug-session-contribution.ts index 456a32783f06b..8392adac28637 100644 --- a/packages/debug/src/browser/debug-session-contribution.ts +++ b/packages/debug/src/browser/debug-session-contribution.ts @@ -22,7 +22,7 @@ import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-servi import { WebSocketConnectionProvider } from '@theia/core/lib/browser/messaging/ws-connection-provider'; import { DebugSession } from './debug-session'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; -import { DebugSessionOptions } from './debug-session-options'; +import { DebugConfigurationSessionOptions, DebugSessionOptions } from './debug-session-options'; import { OutputChannelManager, OutputChannel } from '@theia/output/lib/browser/output-channel'; import { DebugPreferences } from './debug-preferences'; import { DebugSessionConnection } from './debug-session-connection'; @@ -118,7 +118,7 @@ export class DefaultDebugSessionFactory implements DebugSessionFactory { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; - get(sessionId: string, options: DebugSessionOptions, parentSession?: DebugSession): DebugSession { + get(sessionId: string, options: DebugConfigurationSessionOptions, parentSession?: DebugSession): DebugSession { const connection = new DebugSessionConnection( sessionId, () => new Promise(resolve => diff --git a/packages/debug/src/browser/debug-session-manager.ts b/packages/debug/src/browser/debug-session-manager.ts index 7883caaeb6f9c..580e479cb0636 100644 --- a/packages/debug/src/browser/debug-session-manager.ts +++ b/packages/debug/src/browser/debug-session-manager.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { DisposableCollection, Emitter, Event, MessageService, ProgressService, WaitUntilEvent } from '@theia/core'; +import { DisposableCollection, Emitter, Event, MessageService, nls, ProgressService, WaitUntilEvent } from '@theia/core'; import { LabelProvider, ApplicationShell } from '@theia/core/lib/browser'; import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import URI from '@theia/core/lib/common/uri'; @@ -29,7 +29,7 @@ import { BreakpointManager } from './breakpoint/breakpoint-manager'; import { DebugConfigurationManager } from './debug-configuration-manager'; import { DebugSession, DebugState } from './debug-session'; import { DebugSessionContributionRegistry, DebugSessionFactory } from './debug-session-contribution'; -import { DebugSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; +import { DebugCompoundRoot, DebugCompoundSessionOptions, DebugConfigurationSessionOptions, DebugSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; import { DebugStackFrame } from './model/debug-stack-frame'; import { DebugThread } from './model/debug-thread'; import { TaskIdentifier } from '@theia/task/lib/common'; @@ -191,7 +191,19 @@ export class DebugSessionManager { } } - async start(options: DebugSessionOptions): Promise { + async start(options: DebugCompoundSessionOptions): Promise; + async start(options: DebugConfigurationSessionOptions): Promise; + async start(options: DebugSessionOptions): Promise; + async start(name: string): Promise; + async start(optionsOrName: DebugSessionOptions | string): Promise { + if (typeof optionsOrName === 'string') { + const options = this.debugConfigurationManager.find(optionsOrName); + return !!options && this.start(options); + } + return optionsOrName.configuration ? this.startConfiguration(optionsOrName) : this.startCompound(optionsOrName); + } + + protected async startConfiguration(options: DebugConfigurationSessionOptions): Promise { return this.progressService.withProgress('Start...', 'debug', async () => { try { if (!await this.saveAll()) { @@ -200,7 +212,7 @@ export class DebugSessionManager { await this.fireWillStartDebugSession(); const resolved = await this.resolveConfiguration(options); - if (!resolved) { + if (!resolved || !resolved.configuration) { // As per vscode API: https://code.visualstudio.com/api/references/vscode-api#DebugConfigurationProvider // "Returning the value 'undefined' prevents the debug session from starting. // Returning the value 'null' prevents the debug session from starting and opens the @@ -236,13 +248,70 @@ export class DebugSessionManager { }); } + protected async startCompound(options: DebugCompoundSessionOptions): Promise { + let configurations: DebugConfigurationSessionOptions[] = []; + try { + configurations = this.getCompoundConfigurations(options); + } catch (error) { + this.messageService.error(error.message); + return; + } + + if (options.compound.preLaunchTask) { + const taskRun = await this.runTask(options.workspaceFolderUri, options.compound.preLaunchTask, true); + if (!taskRun) { + return undefined; + } + } + + // Compound launch is a success only if each configuration launched successfully + const values = await Promise.all(configurations.map(configuration => this.startConfiguration(configuration))); + const result = values.every(success => !!success); + return result; + } + + protected getCompoundConfigurations(options: DebugCompoundSessionOptions): DebugConfigurationSessionOptions[] { + const compound = options.compound; + if (!compound.configurations) { + throw new Error(nls.localizeByDefault('Compound must have "configurations" attribute set in order to start multiple configurations.')); + } + + const compoundRoot = compound.stopAll ? new DebugCompoundRoot() : undefined; + const configurations: DebugConfigurationSessionOptions[] = []; + for (const configData of compound.configurations) { + const name = typeof configData === 'string' ? configData : configData.name; + if (name === compound.name) { + throw new Error(nls.localize('theia/debug/compound-cycle', "Launch configuration '{0}' contains a cycle with itself", name)); + } + + const workspaceFolderUri = typeof configData === 'string' ? options.workspaceFolderUri : configData.folder; + const matchingOptions = [...this.debugConfigurationManager.all] + .filter(option => option.name === name && !!option.configuration && option.workspaceFolderUri === workspaceFolderUri); + if (matchingOptions.length === 1) { + const match = matchingOptions[0]; + if (DebugSessionOptions.isConfiguration(match)) { + configurations.push({ ...match, compoundRoot, configuration: { ...match.configuration, noDebug: options.noDebug } }); + } else { + throw new Error(nls.localizeByDefault("Could not find launch configuration '{0}' in the workspace.", name)); + } + } else { + throw new Error(matchingOptions.length === 0 + ? workspaceFolderUri + ? nls.localizeByDefault("Can not find folder with name '{0}' for configuration '{1}' in compound '{2}'.", workspaceFolderUri, name, compound.name) + : nls.localizeByDefault("Could not find launch configuration '{0}' in the workspace.", name) + : nls.localizeByDefault("There are multiple launch configurations '{0}' in the workspace. Use folder name to qualify the configuration.", name)); + } + } + return configurations; + } + protected async fireWillStartDebugSession(): Promise { await WaitUntilEvent.fire(this.onWillStartDebugSessionEmitter, {}); } protected configurationIds = new Map(); protected async resolveConfiguration( - options: Readonly + options: Readonly ): Promise { if (InternalDebugSessionOptions.is(options)) { return options; @@ -275,10 +344,12 @@ export class DebugSessionManager { const key = configuration.name + workspaceFolderUri; const id = this.configurationIds.has(key) ? this.configurationIds.get(key)! + 1 : 0; this.configurationIds.set(key, id); + return { id, - configuration, - workspaceFolderUri + ...options, + name: configuration.name, + configuration }; } @@ -301,7 +372,7 @@ export class DebugSessionManager { return this.debug.resolveDebugConfigurationWithSubstitutedVariables(configuration, workspaceFolderUri); } - protected async doStart(sessionId: string, options: DebugSessionOptions): Promise { + protected async doStart(sessionId: string, options: DebugConfigurationSessionOptions): Promise { const parentSession = options.configuration.parentSession && this._sessions.get(options.configuration.parentSession.id); const contrib = this.sessionContributionRegistry.get(options.configuration.type); const sessionFactory = contrib ? contrib.debugSessionFactory() : this.debugSessionFactory; diff --git a/packages/debug/src/browser/debug-session-options.ts b/packages/debug/src/browser/debug-session-options.ts index 108762e0c0fd1..d4a9bb66c2ac6 100644 --- a/packages/debug/src/browser/debug-session-options.ts +++ b/packages/debug/src/browser/debug-session-options.ts @@ -14,44 +14,101 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** +import { Emitter } from '@theia/core'; import { DebugConfiguration } from '../common/debug-common'; +import { DebugCompound } from '../common/debug-compound'; + +export class DebugCompoundRoot { + private stopped = false; + private stopEmitter = new Emitter(); + onDidSessionStop = this.stopEmitter.event; + + stopSession(): void { + if (!this.stopped) { // avoid sending extraneous terminate events + this.stopped = true; + this.stopEmitter.fire(); + } + } +} export interface DebugSessionOptionsBase { workspaceFolderUri?: string, - providerType?: string // Applicable to dynamic configurations } -export interface DebugSessionOptions extends DebugSessionOptionsBase { - configuration: DebugConfiguration +export interface DebugConfigurationSessionOptions extends DebugSessionOptionsBase { + name: string; // derived from the configuration + configuration: DebugConfiguration; + compound?: never; + compoundRoot?: DebugCompoundRoot; + providerType?: string // Applicable to dynamic configurations } -export interface DebugSessionOptionsData extends DebugSessionOptionsBase, DebugConfiguration { +export type DynamicDebugConfigurationSessionOptions = DebugConfigurationSessionOptions & { providerType: string }; + +export interface DebugCompoundSessionOptions extends DebugSessionOptionsBase { + name: string; // derived from the compound + configuration?: never; + compound: DebugCompound; + noDebug?: boolean, } -export interface InternalDebugSessionOptions extends DebugSessionOptions { - id: number +export type DebugSessionOptions = DebugConfigurationSessionOptions | DebugCompoundSessionOptions; + +export namespace DebugSessionOptions { + export function isConfiguration(options?: DebugSessionOptions): options is DebugConfigurationSessionOptions { + return !!options && 'configuration' in options && !!options.configuration; + } + + export function isDynamic(options?: DebugSessionOptions): options is DynamicDebugConfigurationSessionOptions { + return isConfiguration(options) && 'providerType' in options && !!options.providerType; + } + + export function isCompound(options?: DebugSessionOptions): options is DebugCompoundSessionOptions { + return !!options && 'compound' in options && !!options.compound; + } } + +/** + * Flat and partial version of a debug session options usable to find the options later in the manager. + * @deprecated Not needed anymore, the recommended way is to serialize/deserialize the options directly using `JSON.stringify` and `JSON.parse`. + */ +export type DebugSessionOptionsData = DebugSessionOptionsBase & (DebugConfiguration | DebugCompound); + +export type InternalDebugSessionOptions = DebugSessionOptions & { id: number }; + export namespace InternalDebugSessionOptions { const SEPARATOR = '__CONF__'; + const SEPARATOR_CONFIGS = '__COMP__'; export function is(options: DebugSessionOptions): options is InternalDebugSessionOptions { return 'id' in options; } - export function toValue(debugSessionOptions: DebugSessionOptions): string { - return debugSessionOptions.configuration.name + SEPARATOR + - debugSessionOptions.configuration.type + SEPARATOR + - debugSessionOptions.configuration.request + SEPARATOR + - debugSessionOptions.workspaceFolderUri + SEPARATOR + - debugSessionOptions.providerType; + /** @deprecated Please use `JSON.stringify` to serialize the options. */ + export function toValue(options: DebugSessionOptions): string { + if (DebugSessionOptions.isCompound(options)) { + return options.compound.name + SEPARATOR + + options.workspaceFolderUri + SEPARATOR + + options.compound?.configurations.join(SEPARATOR_CONFIGS); + } + return options.configuration.name + SEPARATOR + + options.configuration.type + SEPARATOR + + options.configuration.request + SEPARATOR + + options.workspaceFolderUri + SEPARATOR + + options.providerType; } + /** @deprecated Please use `JSON.parse` to restore previously serialized debug session options. */ + // eslint-disable-next-line deprecation/deprecation export function parseValue(value: string): DebugSessionOptionsData { const split = value.split(SEPARATOR); - if (split.length !== 5) { - throw new Error('Unexpected argument, the argument is expected to have been generated by the \'toValue\' function'); + if (split.length === 5) { + return { name: split[0], type: split[1], request: split[2], workspaceFolderUri: split[3], providerType: split[4] }; + } + if (split.length === 3) { + return { name: split[0], workspaceFolderUri: split[1], configurations: split[2].split(SEPARATOR_CONFIGS) }; } - return {name: split[0], type: split[1], request: split[2], workspaceFolderUri: split[3], providerType: split[4]}; + throw new Error('Unexpected argument, the argument is expected to have been generated by the \'toValue\' function'); } } diff --git a/packages/debug/src/browser/debug-session.tsx b/packages/debug/src/browser/debug-session.tsx index 86feb898b29bb..b81cbb8c415f9 100644 --- a/packages/debug/src/browser/debug-session.tsx +++ b/packages/debug/src/browser/debug-session.tsx @@ -33,7 +33,7 @@ import { DebugSourceBreakpoint } from './model/debug-source-breakpoint'; import debounce = require('p-debounce'); import URI from '@theia/core/lib/common/uri'; import { BreakpointManager } from './breakpoint/breakpoint-manager'; -import { DebugSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; +import { DebugConfigurationSessionOptions, InternalDebugSessionOptions } from './debug-session-options'; import { DebugConfiguration, DebugConsoleMode } from '../common/debug-common'; import { SourceBreakpoint, ExceptionBreakpoint } from './breakpoint/breakpoint-marker'; import { TerminalWidgetOptions, TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; @@ -76,7 +76,7 @@ export class DebugSession implements CompositeTreeElement { constructor( readonly id: string, - readonly options: DebugSessionOptions, + readonly options: DebugConfigurationSessionOptions, readonly parentSession: DebugSession | undefined, protected readonly connection: DebugSessionConnection, protected readonly terminalServer: TerminalService, @@ -117,6 +117,9 @@ export class DebugSession implements CompositeTreeElement { this.connection.on('capabilities', event => this.updateCapabilities(event.body.capabilities)), this.breakpoints.onDidChangeMarkers(uri => this.updateBreakpoints({ uri, sourceModified: true })) ]); + if (this.options.compoundRoot) { + this.toDispose.push(this.options.compoundRoot.onDidSessionStop(() => this.stop(false, () => { }))); + } } get onDispose(): Event { @@ -352,6 +355,9 @@ export class DebugSession implements CompositeTreeElement { console.error('Error on disconnect', e); } } + if (!isRestart) { + this.options.compoundRoot?.stopSession(); + } callback(); } } diff --git a/packages/debug/src/browser/view/debug-configuration-select.tsx b/packages/debug/src/browser/view/debug-configuration-select.tsx index 55bb76ee24706..091ef1b3b31e4 100644 --- a/packages/debug/src/browser/view/debug-configuration-select.tsx +++ b/packages/debug/src/browser/view/debug-configuration-select.tsx @@ -17,7 +17,7 @@ import URI from '@theia/core/lib/common/uri'; import * as React from '@theia/core/shared/react'; import { DebugConfigurationManager } from '../debug-configuration-manager'; -import { DebugSessionOptions, InternalDebugSessionOptions } from '../debug-session-options'; +import { DebugSessionOptions } from '../debug-session-options'; import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component'; import { QuickInputService } from '@theia/core/lib/browser'; import { nls } from '@theia/core/lib/common/nls'; @@ -40,6 +40,7 @@ export class DebugConfigurationSelect extends React.Component(); private manager: DebugConfigurationManager; @@ -65,7 +66,7 @@ export class DebugConfigurationSelect extends React.Component this.setCurrentConfiguration(option)} onFocus={() => this.refreshDebugConfigurations()} onBlur={() => this.refreshDebugConfigurations()} @@ -75,7 +76,26 @@ export class DebugConfigurationSelect extends React.Component + option.userData === DebugConfigurationSelect.CONFIG_MARKER + && this.matchesOption(JSON.parse(option.value!), current) + ); + return matchingOption; + } + + protected matchesOption(sessionOption: DebugSessionOptions, current: DebugSessionOptions): boolean { + const matchesNameAndWorkspace = sessionOption.name === current.name && sessionOption.workspaceFolderUri === current.workspaceFolderUri; + return DebugSessionOptions.isConfiguration(sessionOption) && DebugSessionOptions.isConfiguration(current) + ? matchesNameAndWorkspace && sessionOption.providerType === current.providerType + : matchesNameAndWorkspace; } protected readonly setCurrentConfiguration = (option: SelectOption) => { @@ -88,12 +108,9 @@ export class DebugConfigurationSelect extends React.Component { const configsPerType = await this.manager.provideDynamicDebugConfigurations(); const providerTypes = []; - for (const [ type, configurations ] of Object.entries(configsPerType)) { + for (const [type, configurations] of Object.entries(configsPerType)) { if (configurations.length > 0) { providerTypes.push(type); } } - this.selectRef.current!.value = this.currentValue; - this.setState({ providerTypes, currentValue: this.currentValue }); + + const value = this.currentValue; + this.selectRef.current!.value = value; + this.setState({ providerTypes, currentValue: value }); }; protected renderOptions(): SelectOption[] { @@ -165,10 +184,11 @@ export class DebugConfigurationSelect extends React.Component { - let configuration: DebugConfiguration | undefined; - + // search for matching options + let sessionOptions: TheiaDebugSessionOptions | undefined; if (typeof nameOrConfiguration === 'string') { for (const configOptions of this.configurationManager.all) { - if (configOptions.configuration.name === nameOrConfiguration) { - configuration = configOptions.configuration; + if (configOptions.name === nameOrConfiguration) { + sessionOptions = configOptions; } } } else { - configuration = nameOrConfiguration; + sessionOptions = { + name: nameOrConfiguration.name, + configuration: nameOrConfiguration + }; } - if (!configuration) { + if (!sessionOptions) { console.error(`There is no debug configuration for ${nameOrConfiguration}`); return false; } - const debugConfiguration = { ...configuration, ...options }; - const session = await this.sessionManager.start({ - configuration: debugConfiguration, - workspaceFolderUri: folder && Uri.revive(folder.uri).toString() - }); + // translate given extra data + const workspaceFolderUri = folder && Uri.revive(folder.uri).toString(); + if (TheiaDebugSessionOptions.isConfiguration(sessionOptions)) { + sessionOptions = { ...sessionOptions, configuration: { ...sessionOptions.configuration, ...options }, workspaceFolderUri }; + } else { + sessionOptions = { ...sessionOptions, ...options, workspaceFolderUri }; + } + // start options + const session = await this.sessionManager.start(sessionOptions); return !!session; } diff --git a/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts b/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts index f5226a2dec68f..0048b2c627816 100644 --- a/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts +++ b/packages/plugin-ext/src/main/browser/debug/plugin-debug-session-factory.ts @@ -22,7 +22,7 @@ import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { MessageClient } from '@theia/core/lib/common/message-service-protocol'; import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; import { DebugPreferences } from '@theia/debug/lib/browser/debug-preferences'; -import { DebugSessionOptions } from '@theia/debug/lib/browser/debug-session-options'; +import { DebugConfigurationSessionOptions } from '@theia/debug/lib/browser/debug-session-options'; import { DebugSession } from '@theia/debug/lib/browser/debug-session'; import { DebugSessionConnection } from '@theia/debug/lib/browser/debug-session-connection'; import { TerminalWidgetOptions, TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; @@ -36,7 +36,7 @@ import { PluginChannel } from '../../../common/connection'; export class PluginDebugSession extends DebugSession { constructor( override readonly id: string, - override readonly options: DebugSessionOptions, + override readonly options: DebugConfigurationSessionOptions, override readonly parentSession: DebugSession | undefined, protected override readonly connection: DebugSessionConnection, protected override readonly terminalServer: TerminalService, @@ -80,7 +80,7 @@ export class PluginDebugSessionFactory extends DefaultDebugSessionFactory { super(); } - override get(sessionId: string, options: DebugSessionOptions, parentSession?: DebugSession): DebugSession { + override get(sessionId: string, options: DebugConfigurationSessionOptions, parentSession?: DebugSession): DebugSession { const connection = new DebugSessionConnection( sessionId, this.connectionFactory,