/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { MainContext, type ExtHostTerminalShellIntegrationShape, type MainThreadTerminalShellIntegrationShape } from './extHost.protocol.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import { IExtHostTerminalService } from './extHostTerminalService.js'; import { Emitter, type Event } from '../../../base/common/event.js'; import { URI } from '../../../base/common/uri.js'; import { AsyncIterableObject, Barrier, type AsyncIterableEmitter } from '../../../base/common/async.js'; export interface IExtHostTerminalShellIntegration extends ExtHostTerminalShellIntegrationShape { readonly _serviceBrand: undefined; readonly onDidChangeTerminalShellIntegration: Event; readonly onDidStartTerminalShellExecution: Event; readonly onDidEndTerminalShellExecution: Event; } export const IExtHostTerminalShellIntegration = createDecorator('IExtHostTerminalShellIntegration'); export class ExtHostTerminalShellIntegration extends Disposable implements IExtHostTerminalShellIntegration { readonly _serviceBrand: undefined; protected _proxy: MainThreadTerminalShellIntegrationShape; private _activeShellIntegrations: Map = new Map(); protected readonly _onDidChangeTerminalShellIntegration = new Emitter(); readonly onDidChangeTerminalShellIntegration = this._onDidChangeTerminalShellIntegration.event; protected readonly _onDidStartTerminalShellExecution = new Emitter(); readonly onDidStartTerminalShellExecution = this._onDidStartTerminalShellExecution.event; protected readonly _onDidEndTerminalShellExecution = new Emitter(); readonly onDidEndTerminalShellExecution = this._onDidEndTerminalShellExecution.event; constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, @IExtHostTerminalService private readonly _extHostTerminalService: IExtHostTerminalService, ) { super(); this._proxy = extHostRpc.getProxy(MainContext.MainThreadTerminalShellIntegration); // Clean up listeners this._register(toDisposable(() => { for (const [_, integration] of this._activeShellIntegrations) { integration.dispose(); } this._activeShellIntegrations.clear(); })); // Convenient test code: // this.onDidChangeTerminalShellIntegration(e => { // console.log('*** onDidChangeTerminalShellIntegration', e); // }); // this.onDidStartTerminalShellExecution(async e => { // console.log('*** onDidStartTerminalShellExecution', e); // // new Promise(r => { // // (async () => { // // for await (const d of e.execution.read()) { // // console.log('data2', d); // // } // // })(); // // }); // for await (const d of e.execution.read()) { // console.log('data', d); // } // }); // this.onDidEndTerminalShellExecution(e => { // console.log('*** onDidEndTerminalShellExecution', e); // }); // setTimeout(() => { // console.log('before executeCommand(\"echo hello\")'); // Array.from(this._activeShellIntegrations.values())[0].value.executeCommand('echo hello'); // console.log('after executeCommand(\"echo hello\")'); // }, 4000); } public $shellIntegrationChange(instanceId: number, supportsExecuteCommandApi: boolean): void { const terminal = this._extHostTerminalService.getTerminalById(instanceId); if (!terminal) { return; } const apiTerminal = terminal.value; let shellIntegration = this._activeShellIntegrations.get(instanceId); if (!shellIntegration) { shellIntegration = new InternalTerminalShellIntegration(terminal.value, supportsExecuteCommandApi, this._onDidStartTerminalShellExecution); this._activeShellIntegrations.set(instanceId, shellIntegration); shellIntegration.store.add(terminal.onWillDispose(() => this._activeShellIntegrations.get(instanceId)?.dispose())); shellIntegration.store.add(shellIntegration.onDidRequestShellExecution(commandLine => this._proxy.$executeCommand(instanceId, commandLine))); shellIntegration.store.add(shellIntegration.onDidRequestEndExecution(e => this._onDidEndTerminalShellExecution.fire(e))); shellIntegration.store.add(shellIntegration.onDidRequestChangeShellIntegration(e => this._onDidChangeTerminalShellIntegration.fire(e))); terminal.shellIntegration = shellIntegration.value; } this._onDidChangeTerminalShellIntegration.fire({ terminal: apiTerminal, shellIntegration: shellIntegration.value }); } public $shellExecutionStart(instanceId: number, supportsExecuteCommandApi: boolean, commandLineValue: string, commandLineConfidence: TerminalShellExecutionCommandLineConfidence, isTrusted: boolean, cwd: string | undefined): void { // Force shellIntegration creation if it hasn't been created yet, this could when events // don't come through on startup if (!this._activeShellIntegrations.has(instanceId)) { this.$shellIntegrationChange(instanceId, supportsExecuteCommandApi); } const commandLine: vscode.TerminalShellExecutionCommandLine = { value: commandLineValue, confidence: commandLineConfidence, isTrusted }; this._activeShellIntegrations.get(instanceId)?.startShellExecution(commandLine, this._convertCwdToUri(cwd)); } public $shellExecutionEnd(instanceId: number, commandLineValue: string, commandLineConfidence: TerminalShellExecutionCommandLineConfidence, isTrusted: boolean, exitCode: number | undefined): void { const commandLine: vscode.TerminalShellExecutionCommandLine = { value: commandLineValue, confidence: commandLineConfidence, isTrusted }; this._activeShellIntegrations.get(instanceId)?.endShellExecution(commandLine, exitCode); } public $shellExecutionData(instanceId: number, data: string): void { this._activeShellIntegrations.get(instanceId)?.emitData(data); } public $shellEnvChange(instanceId: number, shellEnvKeys: string[], shellEnvValues: string[], isTrusted: boolean): void { this._activeShellIntegrations.get(instanceId)?.setEnv(shellEnvKeys, shellEnvValues, isTrusted); } public $cwdChange(instanceId: number, cwd: string | undefined): void { this._activeShellIntegrations.get(instanceId)?.setCwd(this._convertCwdToUri(cwd)); } public $closeTerminal(instanceId: number): void { this._activeShellIntegrations.get(instanceId)?.dispose(); this._activeShellIntegrations.delete(instanceId); } private _convertCwdToUri(cwd: string | undefined): URI | undefined { // IMPORTANT: cwd is provided to the exthost as a string from the renderer and only // converted to a URI on the machine in which the pty is hosted on. The string version of // the cwd is used from the renderer such that it's access is synchronous and its event // comes through in order relative to other shell integration events. return cwd ? URI.file(cwd) : undefined; } } interface IExecutionProperties { isMultiLine: boolean; unresolvedCommandLines: string[] | undefined; } export class InternalTerminalShellIntegration extends Disposable { private _pendingExecutions: InternalTerminalShellExecution[] = []; private _pendingEndingExecution: InternalTerminalShellExecution | undefined; private _currentExecutionProperties: IExecutionProperties | undefined; private _currentExecution: InternalTerminalShellExecution | undefined; get currentExecution(): InternalTerminalShellExecution | undefined { return this._currentExecution; } private _env: vscode.TerminalShellIntegrationEnvironment | undefined; private _cwd: URI | undefined; readonly store: DisposableStore = this._register(new DisposableStore()); readonly value: vscode.TerminalShellIntegration; protected readonly _onDidRequestChangeShellIntegration = this._register(new Emitter()); readonly onDidRequestChangeShellIntegration = this._onDidRequestChangeShellIntegration.event; protected readonly _onDidRequestShellExecution = this._register(new Emitter()); readonly onDidRequestShellExecution = this._onDidRequestShellExecution.event; protected readonly _onDidRequestEndExecution = this._register(new Emitter()); readonly onDidRequestEndExecution = this._onDidRequestEndExecution.event; protected readonly _onDidRequestNewExecution = this._register(new Emitter()); readonly onDidRequestNewExecution = this._onDidRequestNewExecution.event; constructor( private readonly _terminal: vscode.Terminal, supportsExecuteCommandApi: boolean, private readonly _onDidStartTerminalShellExecution: Emitter ) { super(); const that = this; this.value = { get cwd(): URI | undefined { return that._cwd; }, get env(): vscode.TerminalShellIntegrationEnvironment | undefined { if (!that._env) { return undefined; } return Object.freeze({ isTrusted: that._env.isTrusted, value: Object.freeze({ ...that._env.value }) }); }, // executeCommand(commandLine: string): vscode.TerminalShellExecution; // executeCommand(executable: string, args: string[]): vscode.TerminalShellExecution; executeCommand(commandLineOrExecutable: string, args?: string[]): vscode.TerminalShellExecution { if (!supportsExecuteCommandApi) { throw new Error('This terminal does not support the executeCommand API.'); } let commandLineValue = commandLineOrExecutable; if (args) { for (const arg of args) { const wrapInQuotes = !arg.match(/["'`]/) && arg.match(/\s/); if (wrapInQuotes) { commandLineValue += ` "${arg}"`; } else { commandLineValue += ` ${arg}`; } } } that._onDidRequestShellExecution.fire(commandLineValue); // Fire the event in a microtask to allow the extension to use the execution before // the start event fires const commandLine: vscode.TerminalShellExecutionCommandLine = { value: commandLineValue, confidence: TerminalShellExecutionCommandLineConfidence.High, isTrusted: true }; const execution = that.requestNewShellExecution(commandLine, that._cwd).value; return execution; } }; } requestNewShellExecution(commandLine: vscode.TerminalShellExecutionCommandLine, cwd: URI | undefined) { const execution = new InternalTerminalShellExecution(commandLine, cwd ?? this._cwd); const unresolvedCommandLines = splitAndSanitizeCommandLine(commandLine.value); if (unresolvedCommandLines.length > 1) { this._currentExecutionProperties = { isMultiLine: true, unresolvedCommandLines: splitAndSanitizeCommandLine(commandLine.value), }; } this._pendingExecutions.push(execution); this._onDidRequestNewExecution.fire(commandLine.value); return execution; } startShellExecution(commandLine: vscode.TerminalShellExecutionCommandLine, cwd: URI | undefined): undefined { // Since an execution is starting, fire the end event for any execution that is awaiting to // end. When this happens it means that the data stream may not be flushed and therefore may // fire events after the end event. if (this._pendingEndingExecution) { this._onDidRequestEndExecution.fire({ terminal: this._terminal, shellIntegration: this.value, execution: this._pendingEndingExecution.value, exitCode: undefined }); this._pendingEndingExecution = undefined; } if (this._currentExecution) { // If the current execution is multi-line, check if this command line is part of it. if (this._currentExecutionProperties?.isMultiLine && this._currentExecutionProperties.unresolvedCommandLines) { const subExecutionResult = isSubExecution(this._currentExecutionProperties.unresolvedCommandLines, commandLine); if (subExecutionResult) { this._currentExecutionProperties.unresolvedCommandLines = subExecutionResult.unresolvedCommandLines; return; } } this._currentExecution.endExecution(undefined); this._currentExecution.flush(); this._onDidRequestEndExecution.fire({ terminal: this._terminal, shellIntegration: this.value, execution: this._currentExecution.value, exitCode: undefined }); } // Get the matching pending execution, how strict this is depends on the confidence of the // command line let currentExecution: InternalTerminalShellExecution | undefined; if (commandLine.confidence === TerminalShellExecutionCommandLineConfidence.High) { for (const [i, execution] of this._pendingExecutions.entries()) { if (execution.value.commandLine.value === commandLine.value) { currentExecution = execution; this._currentExecutionProperties = { isMultiLine: false, unresolvedCommandLines: undefined, }; currentExecution = execution; this._pendingExecutions.splice(i, 1); break; } else { const subExecutionResult = isSubExecution(splitAndSanitizeCommandLine(execution.value.commandLine.value), commandLine); if (subExecutionResult) { this._currentExecutionProperties = { isMultiLine: true, unresolvedCommandLines: subExecutionResult.unresolvedCommandLines, }; currentExecution = execution; this._pendingExecutions.splice(i, 1); break; } } } } else { currentExecution = this._pendingExecutions.shift(); } // If there is no execution, create a new one if (!currentExecution) { // Fallback to the shell integration's cwd as the cwd may not have been restored after a reload currentExecution = new InternalTerminalShellExecution(commandLine, cwd ?? this._cwd); } this._currentExecution = currentExecution; this._onDidStartTerminalShellExecution.fire({ terminal: this._terminal, shellIntegration: this.value, execution: this._currentExecution.value }); } emitData(data: string): void { this.currentExecution?.emitData(data); } endShellExecution(commandLine: vscode.TerminalShellExecutionCommandLine | undefined, exitCode: number | undefined): void { // If the current execution is multi-line, don't end it until the next command line is // confirmed to not be a part of it. if (this._currentExecutionProperties?.isMultiLine) { if (this._currentExecutionProperties.unresolvedCommandLines && this._currentExecutionProperties.unresolvedCommandLines.length > 0) { return; } } if (this._currentExecution) { const commandLineForEvent = this._currentExecutionProperties?.isMultiLine ? this._currentExecution.value.commandLine : commandLine; this._currentExecution.endExecution(commandLineForEvent); const currentExecution = this._currentExecution; this._pendingEndingExecution = currentExecution; this._currentExecution = undefined; // IMPORTANT: Ensure the current execution's data events are flushed in order to // prevent data events firing after the end event fires. currentExecution.flush().then(() => { // Only fire if it's still the same execution, if it's changed it would have already // been fired. if (this._pendingEndingExecution === currentExecution) { this._onDidRequestEndExecution.fire({ terminal: this._terminal, shellIntegration: this.value, execution: currentExecution.value, exitCode }); this._pendingEndingExecution = undefined; } }); } } setEnv(keys: string[], values: string[], isTrusted: boolean): void { const env: { [key: string]: string | undefined } = {}; for (let i = 0; i < keys.length; i++) { env[keys[i]] = values[i]; } this._env = { value: env, isTrusted }; this._fireChangeEvent(); } setCwd(cwd: URI | undefined): void { let wasChanged = false; if (URI.isUri(this._cwd)) { wasChanged = !URI.isUri(cwd) || this._cwd.toString() !== cwd.toString(); } else if (this._cwd !== cwd) { wasChanged = true; } if (wasChanged) { this._cwd = cwd; this._fireChangeEvent(); } } private _fireChangeEvent() { this._onDidRequestChangeShellIntegration.fire({ terminal: this._terminal, shellIntegration: this.value }); } } class InternalTerminalShellExecution { readonly value: vscode.TerminalShellExecution; private _dataStream: ShellExecutionDataStream | undefined; private _isEnded: boolean = false; constructor( private _commandLine: vscode.TerminalShellExecutionCommandLine, readonly cwd: URI | undefined, ) { const that = this; this.value = { get commandLine(): vscode.TerminalShellExecutionCommandLine { return that._commandLine; }, get cwd(): URI | undefined { return that.cwd; }, read(): AsyncIterable { return that._createDataStream(); } }; } private _createDataStream(): AsyncIterable { if (!this._dataStream) { if (this._isEnded) { return AsyncIterableObject.EMPTY; } this._dataStream = new ShellExecutionDataStream(); } return this._dataStream.createIterable(); } emitData(data: string): void { if (!this._isEnded) { this._dataStream?.emitData(data); } } endExecution(commandLine: vscode.TerminalShellExecutionCommandLine | undefined): void { if (commandLine) { this._commandLine = commandLine; } this._dataStream?.endExecution(); this._isEnded = true; } async flush(): Promise { if (this._dataStream) { await this._dataStream.flush(); this._dataStream.dispose(); this._dataStream = undefined; } } } class ShellExecutionDataStream extends Disposable { private _barrier: Barrier | undefined; private _iterables: AsyncIterableObject[] = []; private _emitters: AsyncIterableEmitter[] = []; createIterable(): AsyncIterable { if (!this._barrier) { this._barrier = new Barrier(); } const barrier = this._barrier; const iterable = new AsyncIterableObject(async emitter => { this._emitters.push(emitter); await barrier.wait(); }); this._iterables.push(iterable); return iterable; } emitData(data: string): void { for (const emitter of this._emitters) { emitter.emitOne(data); } } endExecution(): void { this._barrier?.open(); } async flush(): Promise { await Promise.all(this._iterables.map(e => e.toPromise())); } } function splitAndSanitizeCommandLine(commandLine: string): string[] { return commandLine .split('\n') .map(line => line.trim()) .filter(line => line.length > 0); } /** * When executing something that the shell considers multiple commands, such as * a comment followed by a command, this needs to all be tracked under a single * execution. */ function isSubExecution(unresolvedCommandLines: string[], commandLine: vscode.TerminalShellExecutionCommandLine): { unresolvedCommandLines: string[] } | false { if (unresolvedCommandLines.length === 0) { return false; } const newUnresolvedCommandLines = [...unresolvedCommandLines]; const subExecutionLines = splitAndSanitizeCommandLine(commandLine.value); if (newUnresolvedCommandLines && newUnresolvedCommandLines.length > 0) { // If all sub-execution lines are in the command line, this is part of the // multi-line execution. while (newUnresolvedCommandLines.length > 0) { if (newUnresolvedCommandLines[0] !== subExecutionLines[0]) { break; } newUnresolvedCommandLines.shift(); subExecutionLines.shift(); } if (subExecutionLines.length === 0) { return { unresolvedCommandLines: newUnresolvedCommandLines }; } } return false; }