/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { DisposableStore, Disposable, IDisposable, MutableDisposable, combinedDisposable } from '../../../base/common/lifecycle.js'; import { ExtHostContext, ExtHostTerminalServiceShape, MainThreadTerminalServiceShape, MainContext, TerminalLaunchConfig, ITerminalDimensionsDto, ExtHostTerminalIdentifier, TerminalQuickFix, ITerminalCommandDto } from '../common/extHost.protocol.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { URI } from '../../../base/common/uri.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IProcessProperty, IProcessReadyWindowsPty, IShellLaunchConfig, IShellLaunchConfigDto, ITerminalOutputMatch, ITerminalOutputMatcher, ProcessPropertyType, TerminalExitReason, TerminalLocation } from '../../../platform/terminal/common/terminal.js'; import { TerminalDataBufferer } from '../../../platform/terminal/common/terminalDataBuffering.js'; import { ITerminalEditorService, ITerminalExternalLinkProvider, ITerminalGroupService, ITerminalInstance, ITerminalLink, ITerminalService } from '../../contrib/terminal/browser/terminal.js'; import { TerminalProcessExtHostProxy } from '../../contrib/terminal/browser/terminalProcessExtHostProxy.js'; import { IEnvironmentVariableService } from '../../contrib/terminal/common/environmentVariable.js'; import { deserializeEnvironmentDescriptionMap, deserializeEnvironmentVariableCollection, serializeEnvironmentVariableCollection } from '../../../platform/terminal/common/environmentVariableShared.js'; import { IStartExtensionTerminalRequest, ITerminalProcessExtHostProxy, ITerminalProfileResolverService, ITerminalProfileService } from '../../contrib/terminal/common/terminal.js'; import { IRemoteAgentService } from '../../services/remote/common/remoteAgentService.js'; import { OperatingSystem, OS } from '../../../base/common/platform.js'; import { TerminalEditorLocationOptions } from 'vscode'; import { Promises } from '../../../base/common/async.js'; import { ISerializableEnvironmentDescriptionMap, ISerializableEnvironmentVariableCollection } from '../../../platform/terminal/common/environmentVariable.js'; import { ITerminalLinkProviderService } from '../../contrib/terminalContrib/links/browser/links.js'; import { ITerminalQuickFixService, ITerminalQuickFix, TerminalQuickFixType } from '../../contrib/terminalContrib/quickFix/browser/quickFix.js'; import { TerminalCapability } from '../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalCompletionService } from '../../contrib/terminalContrib/suggest/browser/terminalCompletionService.js'; @extHostNamedCustomer(MainContext.MainThreadTerminalService) export class MainThreadTerminalService implements MainThreadTerminalServiceShape { private readonly _store = new DisposableStore(); private readonly _proxy: ExtHostTerminalServiceShape; /** * Stores a map from a temporary terminal id (a UUID generated on the extension host side) * to a numeric terminal id (an id generated on the renderer side) * This comes in play only when dealing with terminals created on the extension host side */ private readonly _extHostTerminals = new Map>(); private readonly _terminalProcessProxies = new Map(); private readonly _profileProviders = new Map(); private readonly _completionProviders = new Map(); private readonly _quickFixProviders = new Map(); private readonly _dataEventTracker = new MutableDisposable(); private readonly _sendCommandEventListener = new MutableDisposable(); /** * A single shared terminal link provider for the exthost. When an ext registers a link * provider, this is registered with the terminal on the renderer side and all links are * provided through this, even from multiple ext link providers. Xterm should remove lower * priority intersecting links itself. */ private readonly _linkProvider = this._store.add(new MutableDisposable()); private _os: OperatingSystem = OS; constructor( private readonly _extHostContext: IExtHostContext, @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalLinkProviderService private readonly _terminalLinkProviderService: ITerminalLinkProviderService, @ITerminalQuickFixService private readonly _terminalQuickFixService: ITerminalQuickFixService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IEnvironmentVariableService private readonly _environmentVariableService: IEnvironmentVariableService, @ILogService private readonly _logService: ILogService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService, @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @ITerminalCompletionService private readonly _terminalCompletionService: ITerminalCompletionService, ) { this._proxy = _extHostContext.getProxy(ExtHostContext.ExtHostTerminalService); // ITerminalService listeners this._store.add(_terminalService.onDidCreateInstance((instance) => { this._onTerminalOpened(instance); this._onInstanceDimensionsChanged(instance); })); this._store.add(_terminalService.onDidDisposeInstance(instance => this._onTerminalDisposed(instance))); this._store.add(_terminalService.onAnyInstanceProcessIdReady(instance => this._onTerminalProcessIdReady(instance))); this._store.add(_terminalService.onDidChangeInstanceDimensions(instance => this._onInstanceDimensionsChanged(instance))); this._store.add(_terminalService.onAnyInstanceMaximumDimensionsChange(instance => this._onInstanceMaximumDimensionsChanged(instance))); this._store.add(_terminalService.onDidRequestStartExtensionTerminal(e => this._onRequestStartExtensionTerminal(e))); this._store.add(_terminalService.onDidChangeActiveInstance(instance => this._onActiveTerminalChanged(instance ? instance.instanceId : null))); this._store.add(_terminalService.onAnyInstanceTitleChange(instance => instance && this._onTitleChanged(instance.instanceId, instance.title))); this._store.add(_terminalService.onAnyInstanceDataInput(instance => this._proxy.$acceptTerminalInteraction(instance.instanceId))); this._store.add(_terminalService.onAnyInstanceSelectionChange(instance => this._proxy.$acceptTerminalSelection(instance.instanceId, instance.selection))); this._store.add(_terminalService.onAnyInstanceShellTypeChanged(instance => this._onShellTypeChanged(instance.instanceId))); // Set initial ext host state for (const instance of this._terminalService.instances) { this._onTerminalOpened(instance); instance.processReady.then(() => this._onTerminalProcessIdReady(instance)); if (instance.shellType) { this._proxy.$acceptTerminalShellType(instance.instanceId, instance.shellType); } } const activeInstance = this._terminalService.activeInstance; if (activeInstance) { this._proxy.$acceptActiveTerminalChanged(activeInstance.instanceId); } if (this._environmentVariableService.collections.size > 0) { const collectionAsArray = [...this._environmentVariableService.collections.entries()]; const serializedCollections: [string, ISerializableEnvironmentVariableCollection][] = collectionAsArray.map(e => { return [e[0], serializeEnvironmentVariableCollection(e[1].map)]; }); this._proxy.$initEnvironmentVariableCollections(serializedCollections); } remoteAgentService.getEnvironment().then(async env => { this._os = env?.os || OS; this._updateDefaultProfile(); }); this._store.add(this._terminalProfileService.onDidChangeAvailableProfiles(() => this._updateDefaultProfile())); } public dispose(): void { this._store.dispose(); for (const provider of this._profileProviders.values()) { provider.dispose(); } for (const provider of this._quickFixProviders.values()) { provider.dispose(); } } private async _updateDefaultProfile() { const remoteAuthority = this._extHostContext.remoteAuthority ?? undefined; const defaultProfile = this._terminalProfileResolverService.getDefaultProfile({ remoteAuthority, os: this._os }); const defaultAutomationProfile = this._terminalProfileResolverService.getDefaultProfile({ remoteAuthority, os: this._os, allowAutomationShell: true }); this._proxy.$acceptDefaultProfile(...await Promise.all([defaultProfile, defaultAutomationProfile])); } private async _getTerminalInstance(id: ExtHostTerminalIdentifier): Promise { if (typeof id === 'string') { return this._extHostTerminals.get(id); } return this._terminalService.getInstanceFromId(id); } public async $createTerminal(extHostTerminalId: string, launchConfig: TerminalLaunchConfig): Promise { const shellLaunchConfig: IShellLaunchConfig = { name: launchConfig.name, executable: launchConfig.shellPath, args: launchConfig.shellArgs, cwd: typeof launchConfig.cwd === 'string' ? launchConfig.cwd : URI.revive(launchConfig.cwd), icon: launchConfig.icon, color: launchConfig.color, initialText: launchConfig.initialText, waitOnExit: launchConfig.waitOnExit, ignoreConfigurationCwd: true, env: launchConfig.env, strictEnv: launchConfig.strictEnv, hideFromUser: launchConfig.hideFromUser, customPtyImplementation: launchConfig.isExtensionCustomPtyTerminal ? (id, cols, rows) => new TerminalProcessExtHostProxy(id, cols, rows, this._terminalService) : undefined, extHostTerminalId, forceShellIntegration: launchConfig.forceShellIntegration, isFeatureTerminal: launchConfig.isFeatureTerminal, isExtensionOwnedTerminal: launchConfig.isExtensionOwnedTerminal, useShellEnvironment: launchConfig.useShellEnvironment, isTransient: launchConfig.isTransient }; const terminal = Promises.withAsyncBody(async r => { const terminal = await this._terminalService.createTerminal({ config: shellLaunchConfig, location: await this._deserializeParentTerminal(launchConfig.location) }); r(terminal); }); this._extHostTerminals.set(extHostTerminalId, terminal); const terminalInstance = await terminal; this._store.add(terminalInstance.onDisposed(() => { this._extHostTerminals.delete(extHostTerminalId); })); } private async _deserializeParentTerminal(location?: TerminalLocation | TerminalEditorLocationOptions | { parentTerminal: ExtHostTerminalIdentifier } | { splitActiveTerminal: boolean; location?: TerminalLocation }): Promise { if (typeof location === 'object' && 'parentTerminal' in location) { const parentTerminal = await this._extHostTerminals.get(location.parentTerminal.toString()); return parentTerminal ? { parentTerminal } : undefined; } return location; } public async $show(id: ExtHostTerminalIdentifier, preserveFocus: boolean): Promise { const terminalInstance = await this._getTerminalInstance(id); if (terminalInstance) { this._terminalService.setActiveInstance(terminalInstance); if (terminalInstance.target === TerminalLocation.Editor) { await this._terminalEditorService.revealActiveEditor(preserveFocus); } else { await this._terminalGroupService.showPanel(!preserveFocus); } } } public async $hide(id: ExtHostTerminalIdentifier): Promise { const instanceToHide = await this._getTerminalInstance(id); const activeInstance = this._terminalService.activeInstance; if (activeInstance && activeInstance.instanceId === instanceToHide?.instanceId && activeInstance.target !== TerminalLocation.Editor) { this._terminalGroupService.hidePanel(); } } public async $dispose(id: ExtHostTerminalIdentifier): Promise { (await this._getTerminalInstance(id))?.dispose(TerminalExitReason.Extension); } public async $sendText(id: ExtHostTerminalIdentifier, text: string, shouldExecute: boolean): Promise { const instance = await this._getTerminalInstance(id); await instance?.sendText(text, shouldExecute); } public $sendProcessExit(terminalId: number, exitCode: number | undefined): void { this._terminalProcessProxies.get(terminalId)?.emitExit(exitCode); } public $startSendingDataEvents(): void { if (!this._dataEventTracker.value) { this._dataEventTracker.value = this._instantiationService.createInstance(TerminalDataEventTracker, (id, data) => { this._onTerminalData(id, data); }); // Send initial events if they exist for (const instance of this._terminalService.instances) { for (const data of instance.initialDataEvents || []) { this._onTerminalData(instance.instanceId, data); } } } } public $stopSendingDataEvents(): void { this._dataEventTracker.clear(); } public $startSendingCommandEvents(): void { if (this._sendCommandEventListener.value) { return; } const multiplexer = this._terminalService.createOnInstanceCapabilityEvent(TerminalCapability.CommandDetection, capability => capability.onCommandFinished); const sub = multiplexer.event(e => { this._onDidExecuteCommand(e.instance.instanceId, { commandLine: e.data.command, // TODO: Convert to URI if possible cwd: e.data.cwd, exitCode: e.data.exitCode, output: e.data.getOutput() }); }); this._sendCommandEventListener.value = combinedDisposable(multiplexer, sub); } public $stopSendingCommandEvents(): void { this._sendCommandEventListener.clear(); } public $startLinkProvider(): void { this._linkProvider.value = this._terminalLinkProviderService.registerLinkProvider(new ExtensionTerminalLinkProvider(this._proxy)); } public $stopLinkProvider(): void { this._linkProvider.clear(); } public $registerProcessSupport(isSupported: boolean): void { this._terminalService.registerProcessSupport(isSupported); } public $registerCompletionProvider(id: string, extensionIdentifier: string, ...triggerCharacters: string[]): void { this._completionProviders.set(id, this._terminalCompletionService.registerTerminalCompletionProvider(extensionIdentifier, id, { id, provideCompletions: async (commandLine, cursorPosition, allowFallbackCompletions, token) => { const completions = await this._proxy.$provideTerminalCompletions(id, { commandLine, cursorPosition, allowFallbackCompletions }, token); return { items: completions?.items.map(c => ({ ...c, provider: id })), resourceRequestConfig: completions?.resourceRequestConfig }; } }, ...triggerCharacters)); } public $unregisterCompletionProvider(id: string): void { this._completionProviders.get(id)?.dispose(); this._completionProviders.delete(id); } public $registerProfileProvider(id: string, extensionIdentifier: string): void { // Proxy profile provider requests through the extension host this._profileProviders.set(id, this._terminalProfileService.registerTerminalProfileProvider(extensionIdentifier, id, { createContributedTerminalProfile: async (options) => { return this._proxy.$createContributedProfileTerminal(id, options); } })); } public $unregisterProfileProvider(id: string): void { this._profileProviders.get(id)?.dispose(); this._profileProviders.delete(id); } public async $registerQuickFixProvider(id: string, extensionId: string): Promise { this._quickFixProviders.set(id, this._terminalQuickFixService.registerQuickFixProvider(id, { provideTerminalQuickFixes: async (terminalCommand, lines, options, token) => { if (token.isCancellationRequested) { return; } if (options.outputMatcher?.length && options.outputMatcher.length > 40) { options.outputMatcher.length = 40; this._logService.warn('Cannot exceed output matcher length of 40'); } const commandLineMatch = terminalCommand.command.match(options.commandLineMatcher); if (!commandLineMatch || !lines) { return; } const outputMatcher = options.outputMatcher; let outputMatch; if (outputMatcher) { outputMatch = getOutputMatchForLines(lines, outputMatcher); } if (!outputMatch) { return; } const matchResult = { commandLineMatch, outputMatch, commandLine: terminalCommand.command }; if (matchResult) { const result = await this._proxy.$provideTerminalQuickFixes(id, matchResult, token); if (result && Array.isArray(result)) { return result.map(r => parseQuickFix(id, extensionId, r)); } else if (result) { return parseQuickFix(id, extensionId, result); } } return; } })); } public $unregisterQuickFixProvider(id: string): void { this._quickFixProviders.get(id)?.dispose(); this._quickFixProviders.delete(id); } private _onActiveTerminalChanged(terminalId: number | null): void { this._proxy.$acceptActiveTerminalChanged(terminalId); } private _onTerminalData(terminalId: number, data: string): void { this._proxy.$acceptTerminalProcessData(terminalId, data); } private _onDidExecuteCommand(terminalId: number, command: ITerminalCommandDto): void { this._proxy.$acceptDidExecuteCommand(terminalId, command); } private _onTitleChanged(terminalId: number, name: string): void { this._proxy.$acceptTerminalTitleChange(terminalId, name); } private _onShellTypeChanged(terminalId: number): void { const terminalInstance = this._terminalService.getInstanceFromId(terminalId); if (terminalInstance) { this._proxy.$acceptTerminalShellType(terminalId, terminalInstance.shellType); } } private _onTerminalDisposed(terminalInstance: ITerminalInstance): void { this._proxy.$acceptTerminalClosed(terminalInstance.instanceId, terminalInstance.exitCode, terminalInstance.exitReason ?? TerminalExitReason.Unknown); } private _onTerminalOpened(terminalInstance: ITerminalInstance): void { const extHostTerminalId = terminalInstance.shellLaunchConfig.extHostTerminalId; const shellLaunchConfigDto: IShellLaunchConfigDto = { name: terminalInstance.shellLaunchConfig.name, executable: terminalInstance.shellLaunchConfig.executable, args: terminalInstance.shellLaunchConfig.args, cwd: terminalInstance.shellLaunchConfig.cwd, env: terminalInstance.shellLaunchConfig.env, hideFromUser: terminalInstance.shellLaunchConfig.hideFromUser, tabActions: terminalInstance.shellLaunchConfig.tabActions }; this._proxy.$acceptTerminalOpened(terminalInstance.instanceId, extHostTerminalId, terminalInstance.title, shellLaunchConfigDto); } private _onTerminalProcessIdReady(terminalInstance: ITerminalInstance): void { if (terminalInstance.processId === undefined) { return; } this._proxy.$acceptTerminalProcessId(terminalInstance.instanceId, terminalInstance.processId); } private _onInstanceDimensionsChanged(instance: ITerminalInstance): void { this._proxy.$acceptTerminalDimensions(instance.instanceId, instance.cols, instance.rows); } private _onInstanceMaximumDimensionsChanged(instance: ITerminalInstance): void { this._proxy.$acceptTerminalMaximumDimensions(instance.instanceId, instance.maxCols, instance.maxRows); } private _onRequestStartExtensionTerminal(request: IStartExtensionTerminalRequest): void { const proxy = request.proxy; this._terminalProcessProxies.set(proxy.instanceId, proxy); // Note that onResize is not being listened to here as it needs to fire when max dimensions // change, excluding the dimension override const initialDimensions: ITerminalDimensionsDto | undefined = request.cols && request.rows ? { columns: request.cols, rows: request.rows } : undefined; this._proxy.$startExtensionTerminal( proxy.instanceId, initialDimensions ).then(request.callback); proxy.onInput(data => this._proxy.$acceptProcessInput(proxy.instanceId, data)); proxy.onShutdown(immediate => this._proxy.$acceptProcessShutdown(proxy.instanceId, immediate)); proxy.onRequestCwd(() => this._proxy.$acceptProcessRequestCwd(proxy.instanceId)); proxy.onRequestInitialCwd(() => this._proxy.$acceptProcessRequestInitialCwd(proxy.instanceId)); } public $sendProcessData(terminalId: number, data: string): void { this._terminalProcessProxies.get(terminalId)?.emitData(data); } public $sendProcessReady(terminalId: number, pid: number, cwd: string, windowsPty: IProcessReadyWindowsPty | undefined): void { this._terminalProcessProxies.get(terminalId)?.emitReady(pid, cwd, windowsPty); } public $sendProcessProperty(terminalId: number, property: IProcessProperty): void { if (property.type === ProcessPropertyType.Title) { const instance = this._terminalService.getInstanceFromId(terminalId); instance?.rename(property.value); } this._terminalProcessProxies.get(terminalId)?.emitProcessProperty(property); } $setEnvironmentVariableCollection(extensionIdentifier: string, persistent: boolean, collection: ISerializableEnvironmentVariableCollection | undefined, descriptionMap: ISerializableEnvironmentDescriptionMap): void { if (collection) { const translatedCollection = { persistent, map: deserializeEnvironmentVariableCollection(collection), descriptionMap: deserializeEnvironmentDescriptionMap(descriptionMap) }; this._environmentVariableService.set(extensionIdentifier, translatedCollection); } else { this._environmentVariableService.delete(extensionIdentifier); } } } /** * Encapsulates temporary tracking of data events from terminal instances, once disposed all * listeners are removed. */ class TerminalDataEventTracker extends Disposable { private readonly _bufferer: TerminalDataBufferer; constructor( private readonly _callback: (id: number, data: string) => void, @ITerminalService private readonly _terminalService: ITerminalService ) { super(); this._register(this._bufferer = new TerminalDataBufferer(this._callback)); for (const instance of this._terminalService.instances) { this._registerInstance(instance); } this._register(this._terminalService.onDidCreateInstance(instance => this._registerInstance(instance))); this._register(this._terminalService.onDidDisposeInstance(instance => this._bufferer.stopBuffering(instance.instanceId))); } private _registerInstance(instance: ITerminalInstance): void { // Buffer data events to reduce the amount of messages going to the extension host this._register(this._bufferer.startBuffering(instance.instanceId, instance.onData)); } } class ExtensionTerminalLinkProvider implements ITerminalExternalLinkProvider { constructor( private readonly _proxy: ExtHostTerminalServiceShape ) { } async provideLinks(instance: ITerminalInstance, line: string): Promise { const proxy = this._proxy; const extHostLinks = await proxy.$provideLinks(instance.instanceId, line); return extHostLinks.map(dto => ({ id: dto.id, startIndex: dto.startIndex, length: dto.length, label: dto.label, activate: () => proxy.$activateLink(instance.instanceId, dto.id) })); } } export function getOutputMatchForLines(lines: string[], outputMatcher: ITerminalOutputMatcher): ITerminalOutputMatch | undefined { const match: RegExpMatchArray | null | undefined = lines.join('\n').match(outputMatcher.lineMatcher); return match ? { regexMatch: match, outputLines: lines } : undefined; } function parseQuickFix(id: string, source: string, fix: TerminalQuickFix): ITerminalQuickFix { let type = TerminalQuickFixType.TerminalCommand; if ('uri' in fix) { fix.uri = URI.revive(fix.uri); type = TerminalQuickFixType.Opener; } else if ('id' in fix) { type = TerminalQuickFixType.VscodeCommand; } return { id, type, source, ...fix }; }