// ***************************************************************************** // Copyright (C) 2018 Red Hat, Inc. 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-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import debounce = require('p-debounce'); import { visit, parse } from 'jsonc-parser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { Emitter, Event, WaitUntilEvent } from '@theia/core/lib/common/event'; import { EditorManager, EditorWidget } from '@theia/editor/lib/browser'; import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; import { LabelProvider, QuickPickValue, StorageService } 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 { DebugConfigurationModel } from './debug-configuration-model'; import { DebugSessionOptions, DynamicDebugConfigurationSessionOptions } from './debug-session-options'; import { DebugService, DynamicDebugConfigurationProvider } from '../common/debug-service'; import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { DebugConfiguration } from '../common/debug-common'; import { WorkspaceVariableContribution } from '@theia/workspace/lib/browser/workspace-variable-contribution'; import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; import * as monaco from '@theia/monaco-editor-core'; import { ICommandService } from '@theia/monaco-editor-core/esm/vs/platform/commands/common/commands'; import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { nls, PreferenceConfigurations, PreferenceScope, PreferenceService } from '@theia/core'; import { DebugCompound } from '../common/debug-compound'; /** * Event fired before providing debug configurations. * When `debugType` is specified, only extensions providing that type should be activated. * When `debugType` is undefined, all configuration providers should be activated. */ export interface WillProvideDebugConfiguration extends WaitUntilEvent { /** * The debug type to provide configurations for. * If undefined, configurations for all types should be provided. */ readonly debugType?: string; } @injectable() export class DebugConfigurationManager { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(DebugService) protected readonly debug: DebugService; @inject(QuickPickService) protected readonly quickPickService: QuickPickService; @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(MonacoTextModelService) protected readonly textModelService: MonacoTextModelService; @inject(PreferenceService) protected readonly preferences: PreferenceService; @inject(PreferenceConfigurations) protected readonly preferenceConfigurations: PreferenceConfigurations; @inject(WorkspaceVariableContribution) protected readonly workspaceVariables: WorkspaceVariableContribution; protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange: Event = this.onDidChangeEmitter.event; protected readonly onWillProvideDebugConfigurationEmitter = new Emitter(); readonly onWillProvideDebugConfiguration: Event = this.onWillProvideDebugConfigurationEmitter.event; protected readonly onWillProvideDynamicDebugConfigurationEmitter = new Emitter(); get onWillProvideDynamicDebugConfiguration(): Event { return this.onWillProvideDynamicDebugConfigurationEmitter.event; } get onDidChangeConfigurationProviders(): Event { return this.debug.onDidChangeDebugConfigurationProviders; } protected readonly onDidChangeDynamicConfigurationsEmitter = new Emitter(); /** * Fires when the set of dynamic debug configuration providers changes * (i.e., when providers are registered or unregistered). */ readonly onDidChangeDynamicConfigurations: Event = this.onDidChangeDynamicConfigurationsEmitter.event; protected debugConfigurationTypeKey: ContextKey; protected initialized: Promise; protected recentDynamicOptionsTracker: DynamicDebugConfigurationSessionOptions[] = []; @postConstruct() protected init(): void { this.doInit(); // Notify when configuration providers change this.debug.onDidChangeDebugConfigurationProviders(() => { this.onDidChangeDynamicConfigurationsEmitter.fire(); }); } protected async doInit(): Promise { this.debugConfigurationTypeKey = this.contextKeyService.createKey('debugConfigurationType', undefined); this.initialized = this.preferences.ready.then(() => { this.workspaceService.onWorkspaceChanged(this.updateModels); this.preferences.onPreferenceChanged(e => { if (e.preferenceName === 'launch') { this.updateModels(); } }); return this.updateModels(); }); } protected readonly models = new Map(); protected updateModels = debounce(async () => { const roots = await this.workspaceService.roots; const toDelete = new Set(this.models.keys()); for (const rootStat of roots) { const key = rootStat.resource.toString(); toDelete.delete(key); if (!this.models.has(key)) { const model = new DebugConfigurationModel(key, this.preferences); model.onDidChange(() => this.updateCurrent()); model.onDispose(() => this.models.delete(key)); this.models.set(key, model); } } for (const uri of toDelete) { const model = this.models.get(uri); if (model) { model.dispose(); } } this.updateCurrent(); }, 500); /** * All _non-dynamic_ debug configurations. */ get all(): IterableIterator { return this.getAll(); } protected *getAll(): IterableIterator { for (const model of this.models.values()) { for (const configuration of model.configurations) { yield this.configurationToOptions(configuration, model.workspaceFolderUri); } for (const compound of model.compounds) { yield this.compoundToOptions(compound, model.workspaceFolderUri); } } } get supported(): Promise> { return this.getSupported(); } protected async getSupported(): Promise> { await this.initialized; const debugTypes = await this.debug.debugTypes(); return this.doGetSupported(new Set(debugTypes)); } protected *doGetSupported(debugTypes: Set): IterableIterator { for (const options of this.getAll()) { if (options.configuration && debugTypes.has(options.configuration.type)) { yield options; } } } protected _currentOptions: DebugSessionOptions | undefined; get current(): DebugSessionOptions | undefined { return this._currentOptions; } async getSelectedConfiguration(): Promise { if (!DebugSessionOptions.isDynamic(this._currentOptions)) { return this._currentOptions; } // Refresh a dynamic configuration from the provider. // This allow providers to update properties before the execution e.g. program const { providerType, workspaceFolderUri, configuration: { name } } = this._currentOptions; const configuration = await this.fetchDynamicDebugConfiguration(name, providerType, workspaceFolderUri); if (!configuration) { const message = nls.localize( 'theia/debug/missingConfiguration', "Dynamic configuration '{0}:{1}' is missing or not applicable", providerType, name); throw new Error(message); } return { name, configuration, providerType, workspaceFolderUri }; } set current(option: DebugSessionOptions | undefined) { this.updateCurrent(option); this.updateRecentlyUsedDynamicConfigurationOptions(option); } protected updateRecentlyUsedDynamicConfigurationOptions(option: DebugSessionOptions | undefined): void { 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) { this.recentDynamicOptionsTracker.splice(index, 1); } // Adding new item, most recent at the top of the list const recentMax = 3; if (this.recentDynamicOptionsTracker.unshift(option) > recentMax) { // Keep the latest 3 dynamic configuration options to not clutter the dropdown. this.recentDynamicOptionsTracker.splice(recentMax); } } } protected dynamicOptionsMatch(one: DynamicDebugConfigurationSessionOptions, other: DynamicDebugConfigurationSessionOptions): boolean { return one.providerType !== undefined && one.configuration.name === other.configuration.name && one.providerType === other.providerType && one.workspaceFolderUri === other.workspaceFolderUri; } get recentDynamicOptions(): readonly DynamicDebugConfigurationSessionOptions[] { return this.recentDynamicOptionsTracker; } protected updateCurrent(options: DebugSessionOptions | undefined = this._currentOptions): void { 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 = this.configurationToOptions(configuration, model.workspaceFolderUri); } } } this.debugConfigurationTypeKey.set(this.current && this.current.configuration?.type); this.onDidChangeEmitter.fire(undefined); } /** * @deprecated since v1.27.0 */ find(name: string, workspaceFolderUri: string): DebugSessionOptions | undefined; /** * Find / Resolve DebugSessionOptions from a given target debug configuration */ 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) { 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; } } } } } async openConfiguration(): Promise { const currentUri = new URI(this.current?.workspaceFolderUri); const model = this.getModel(currentUri); if (model) { await this.doOpen(model); } } 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) { rootUri = await this.selectRootUri(); // Do not continue if the user explicitly does not choose a location. if (!rootUri) { return; } } const model = this.getModel(rootUri); if (!model) { return; } const editor = MonacoEditor.get(await this.doOpen(model))?.getControl(); const editorModel = editor && editor.getModel(); if (!editorModel) { return; } const commandService = StandaloneServices.get(ICommandService); let position: monaco.Position | undefined; let depthInArray = 0; let lastProperty = ''; visit(editor.getValue(), { onObjectProperty: property => { lastProperty = property; }, onArrayBegin: offset => { if (lastProperty === 'configurations' && depthInArray === 0) { position = editorModel!.getPositionAt(offset + 1); } depthInArray++; }, onArrayEnd: () => { depthInArray--; } }); if (!position) { return; } // Check if there are more characters on a line after a "configurations": [, if yes enter a newline if (editorModel.getLineLastNonWhitespaceColumn(position.lineNumber) > position.column) { editor.setPosition(position); editor.trigger('debug', 'lineBreakInsert', undefined); } // Check if there is already an empty line to insert suggest, if yes just place the cursor if (editorModel!.getLineLastNonWhitespaceColumn(position.lineNumber + 1) === 0) { editor.setPosition({ lineNumber: position.lineNumber + 1, column: 1 << 30 }); await commandService.executeCommand('editor.action.deleteLines'); } editor.setPosition(position); await commandService.executeCommand('editor.action.insertLineAfter'); await commandService.executeCommand('editor.action.triggerSuggest'); } protected async selectRootUri(): Promise { const workspaceRoots = this.workspaceService.tryGetRoots(); const items: QuickPickValue[] = []; for (const workspaceRoot of workspaceRoots) { items.push({ label: this.labelProvider.getName(workspaceRoot.resource), description: this.labelProvider.getLongName(workspaceRoot.resource), value: workspaceRoot.resource }); } const root = await this.quickPickService.show(items, { placeholder: nls.localize('theia/debug/addConfigurationPlaceholder', 'Select workspace root to add configuration to'), }); return root?.value; } protected getModel(uri?: URI): DebugConfigurationModel | undefined { const workspaceFolderUri = this.workspaceVariables.getWorkspaceRootUri(uri); if (workspaceFolderUri) { const key = workspaceFolderUri.toString(); for (const model of this.models.values()) { if (model.workspaceFolderUri === key) { return model; } } } for (const model of this.models.values()) { if (model.uri) { return model; } } return this.models.values().next().value; } protected async doOpen(model: DebugConfigurationModel): Promise { const uri = await this.doCreate(model); return this.editorManager.open(uri, { mode: 'activate' }); } protected async doCreate(model: DebugConfigurationModel): Promise { const uri = model.uri ?? this.preferences.getConfigUri(PreferenceScope.Folder, model.workspaceFolderUri, 'launch'); if (!uri) { // Since we are requesting information about a known workspace folder, this should never happen. throw new Error('PreferenceService.getConfigUri has returned undefined when a URI was expected.'); } const settingsUri = this.preferences.getConfigUri(PreferenceScope.Folder, model.workspaceFolderUri); // Users may have placed their debug configurations in a `settings.json`, in which case we shouldn't modify the file. if (settingsUri && !uri.isEqual(settingsUri)) { await this.ensureContent(uri, model); } return uri; } /** * Checks whether a `launch.json` file contains the minimum necessary content. * If content not found, provides content and populates the file using Monaco. */ protected async ensureContent(uri: URI, model: DebugConfigurationModel): Promise { const textModel = await this.textModelService.createModelReference(uri); const currentContent = textModel.object.valid ? textModel.object.getText() : ''; try { // Look for the minimal well-formed launch.json content: {configurations: []} const parsedContent = parse(currentContent); if (Array.isArray(parsedContent.configurations)) { return; } } catch { // Just keep going } const debugType = await this.selectDebugType(); const configurations = debugType ? await this.provideDebugConfigurations(debugType, model.workspaceFolderUri) : []; const content = this.getInitialConfigurationContent(configurations); textModel.object.textEditorModel.setValue(content); // Will clobber anything the user has entered! await textModel.object.save(); } protected async provideDebugConfigurations(debugType: string, workspaceFolderUri: string | undefined): Promise { await this.fireWillProvideDebugConfiguration(); return this.debug.provideDebugConfigurations(debugType, workspaceFolderUri); } protected async fireWillProvideDebugConfiguration(): Promise { await WaitUntilEvent.fire(this.onWillProvideDebugConfigurationEmitter, {}); } /** * Returns dynamic debug configuration providers grouped by label. * Each entry contains a label and all the types that share that label. */ getDynamicDebugConfigurationProviders(): DynamicDebugConfigurationProvider[] { return this.debug.getDynamicDebugConfigurationProviders?.() ?? []; } /** * Returns the types of registered dynamic debug configuration providers * without invoking them. * @deprecated Use getDynamicDebugConfigurationProviders() instead for proper label support. */ getDynamicDebugConfigurationProviderTypes(): string[] { return this.debug.getDynamicDebugConfigurationProviderTypes?.() ?? []; } async provideDynamicDebugConfigurations(): Promise> { await this.fireWillProvideDynamicDebugConfiguration(); const roots = this.workspaceService.tryGetRoots(); const promises = roots.map(async root => { const configsMap = await this.debug.provideDynamicDebugConfigurations!(root.resource.toString()); const optionsMap = Object.fromEntries(Object.entries(configsMap).map(([type, configs]) => { const options = configs.map(config => ({ name: config.name, providerType: type, configuration: config, workspaceFolderUri: root.resource.toString() })); return [type, options]; })); return optionsMap; }); const typesToOptionsRecords = await Promise.all(promises); const consolidatedTypesToOptions: Record = {}; for (const typesToOptionsInstance of typesToOptionsRecords) { for (const [providerType, configurationsOptions] of Object.entries(typesToOptionsInstance)) { if (!consolidatedTypesToOptions[providerType]) { consolidatedTypesToOptions[providerType] = []; } consolidatedTypesToOptions[providerType].push(...configurationsOptions); } } return consolidatedTypesToOptions; } async fetchDynamicDebugConfiguration(name: string, type: string, folder?: string): Promise { await this.fireWillProvideDynamicDebugConfiguration(type); return this.debug.fetchDynamicDebugConfiguration(name, type, folder); } /** * Provides dynamic debug configurations for a specific provider type only. * This activates only the extension for the specified type, making it more * efficient than `provideDynamicDebugConfigurations()` which activates all providers. */ async provideDynamicDebugConfigurationsByType(providerType: string): Promise { await this.fireWillProvideDynamicDebugConfiguration(providerType); const roots = this.workspaceService.tryGetRoots(); const allOptions: DynamicDebugConfigurationSessionOptions[] = []; await Promise.all(roots.map(async root => { const configs = await this.debug.provideDynamicDebugConfigurationsByType?.(providerType, root.resource.toString()) ?? []; const options = configs.map(config => ({ name: config.name, providerType, configuration: config, workspaceFolderUri: root.resource.toString() })); allOptions.push(...options); })); return allOptions; } protected async fireWillProvideDynamicDebugConfiguration(debugType?: string): Promise { await this.initialized; await WaitUntilEvent.fire(this.onWillProvideDynamicDebugConfigurationEmitter, { debugType }); } protected getInitialConfigurationContent(initialConfigurations: DebugConfiguration[]): string { return `{ // ${nls.localizeByDefault('Use IntelliSense to learn about possible attributes.')} // ${nls.localizeByDefault('Hover to view descriptions of existing attributes.')} "version": "0.2.0", "configurations": ${JSON.stringify(initialConfigurations, undefined, ' ').split('\n').map(line => ' ' + line).join('\n').trim()} } `; } protected async selectDebugType(): Promise { const widget = this.editorManager.currentEditor; if (!widget) { return undefined; } const { languageId } = widget.editor.document; const debuggers = await this.debug.getDebuggersForLanguage(languageId); if (debuggers.length === 0) { return undefined; } const items: Array> = debuggers.map(({ label, type }) => ({ label, value: type })); const selectedItem = await this.quickPickService.show(items, { placeholder: nls.localizeByDefault('Select debugger') }); return selectedItem?.value; } @inject(StorageService) protected readonly storage: StorageService; async load(): Promise { await this.initialized; const data = await this.storage.getData('debug.configurations', {}); this.resolveRecentDynamicOptionsFromData(data.recentDynamicOptions); // 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 (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?: DynamicDebugConfigurationSessionOptions[]): void { if (!options || this.recentDynamicOptionsTracker.length !== 0) { return; } // 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 { const data: DebugConfigurationManager.Data = {}; const { current, recentDynamicOptionsTracker } = this; if (current) { data.current = current; } if (this.recentDynamicOptionsTracker.length > 0) { data.recentDynamicOptions = recentDynamicOptionsTracker; } if (Object.keys(data).length > 0) { this.storage.setData('debug.configurations', data); } } } export namespace DebugConfigurationManager { export interface Data { current?: DebugSessionOptions, recentDynamicOptions?: DynamicDebugConfigurationSessionOptions[] } }