diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/terminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/terminal.test.ts index dc281a06fd5..e2787a48fd5 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/terminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/terminal.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { window, Terminal, Pseudoterminal, EventEmitter, TerminalDimensions, workspace, ConfigurationTarget } from 'vscode'; -import { doesNotThrow, equal, ok } from 'assert'; +import { doesNotThrow, equal, ok, deepEqual } from 'assert'; suite('window namespace tests', () => { suiteSetup(async () => { @@ -201,6 +201,51 @@ suite('window namespace tests', () => { }); }); + suite('window.onDidWriteTerminalData', () => { + test('should listen to all future terminal data events', (done) => { + const openEvents: string[] = []; + const dataEvents: { name: string, data: string }[] = []; + const closeEvents: string[] = []; + const reg1 = window.onDidOpenTerminal(e => openEvents.push(e.name)); + const reg2 = window.onDidWriteTerminalData(e => dataEvents.push({ name: e.terminal.name, data: e.data })); + const reg3 = window.onDidCloseTerminal(e => { + closeEvents.push(e.name); + if (closeEvents.length === 2) { + deepEqual(openEvents, [ 'test1', 'test2' ]); + deepEqual(dataEvents, [ { name: 'test1', data: 'write1' }, { name: 'test2', data: 'write2' } ]); + deepEqual(closeEvents, [ 'test1', 'test2' ]); + reg1.dispose(); + reg2.dispose(); + reg3.dispose(); + done(); + } + }); + + const term1Write = new EventEmitter(); + const term1Close = new EventEmitter(); + window.createTerminal({ name: 'test1', pty: { + onDidWrite: term1Write.event, + onDidClose: term1Close.event, + open: () => { + term1Write.fire('write1'); + term1Close.fire(); + const term2Write = new EventEmitter(); + const term2Close = new EventEmitter(); + window.createTerminal({ name: 'test2', pty: { + onDidWrite: term2Write.event, + onDidClose: term2Close.event, + open: () => { + term2Write.fire('write2'); + term2Close.fire(); + }, + close: () => {} + }}); + }, + close: () => {} + }}); + }); + }); + suite('Terminal renderers (deprecated)', () => { test('should fire onDidOpenTerminal and onDidCloseTerminal from createTerminalRenderer terminal', (done) => { const reg1 = window.onDidOpenTerminal(term => { diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index db42ea4b01a..dbef9622c77 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -752,11 +752,29 @@ declare module 'vscode' { readonly dimensions: TerminalDimensions; } + export interface TerminalDataWriteEvent { + /** + * The [terminal](#Terminal) for which the data was written. + */ + readonly terminal: Terminal; + /** + * The data being written. + */ + readonly data: string; + } + namespace window { /** * An event which fires when the [dimensions](#Terminal.dimensions) of the terminal change. */ export const onDidChangeTerminalDimensions: Event; + + /** + * An event which fires when the terminal's pty slave pseudo-device is written to. In other + * words, this provides access to the raw data stream from the process running within the + * terminal, including VT sequences. + */ + export const onDidWriteTerminalData: Event; } export interface Terminal { @@ -771,6 +789,8 @@ declare module 'vscode' { * Fires when the terminal's pty slave pseudo-device is written to. In other words, this * provides access to the raw data stream from the process running within the terminal, * including VT sequences. + * + * @deprecated Use [window.onDidWriteTerminalData](#onDidWriteTerminalData). */ readonly onDidWriteData: Event; } diff --git a/src/vs/workbench/api/browser/mainThreadTerminalService.ts b/src/vs/workbench/api/browser/mainThreadTerminalService.ts index fcb1f70bfb6..f59e50e0758 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalService.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalService.ts @@ -11,6 +11,7 @@ import { URI } from 'vs/base/common/uri'; import { StopWatch } from 'vs/base/common/stopwatch'; import { ITerminalInstanceService } from 'vs/workbench/contrib/terminal/browser/terminal'; import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @extHostNamedCustomer(MainContext.MainThreadTerminalService) export class MainThreadTerminalService implements MainThreadTerminalServiceShape { @@ -22,12 +23,14 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape private readonly _terminalProcessesReady = new Map void>(); private readonly _terminalOnDidWriteDataListeners = new Map(); private readonly _terminalOnDidAcceptInputListeners = new Map(); + private _dataEventTracker: TerminalDataEventTracker | undefined; constructor( extHostContext: IExtHostContext, @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalInstanceService readonly terminalInstanceService: ITerminalInstanceService, - @IRemoteAgentService readonly _remoteAgentService: IRemoteAgentService + @IRemoteAgentService readonly _remoteAgentService: IRemoteAgentService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostTerminalService); this._remoteAuthority = extHostContext.remoteAuthority; @@ -172,6 +175,7 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape } } + /** @deprecated */ public $registerOnDataListener(terminalId: number): void { const terminalInstance = this._terminalService.getInstanceFromId(terminalId); if (!terminalInstance) { @@ -191,14 +195,34 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape terminalInstance.addDisposable(listener); } + public $startSendingDataEvents(): void { + if (!this._dataEventTracker) { + this._dataEventTracker = this._instantiationService.createInstance(TerminalDataEventTracker, (id, data) => { + this._onTerminalData2(id, data); + }); + } + } + + public $stopSendingDataEvents(): void { + if (this._dataEventTracker) { + this._dataEventTracker.dispose(); + this._dataEventTracker = undefined; + } + } + private _onActiveTerminalChanged(terminalId: number | null): void { this._proxy.$acceptActiveTerminalChanged(terminalId); } + /** @deprecated */ private _onTerminalData(terminalId: number, data: string): void { this._proxy.$acceptTerminalProcessData(terminalId, data); } + private _onTerminalData2(terminalId: number, data: string): void { + this._proxy.$acceptTerminalProcessData2(terminalId, data); + } + private _onTitleChanged(terminalId: number, name: string): void { this._proxy.$acceptTerminalTitleChange(terminalId, name); } @@ -371,3 +395,22 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape return terminal; } } + +/** + * Encapsulates temporary tracking of data events from terminal instances, once disposed all + * listeners are removed. + */ +class TerminalDataEventTracker extends DisposableStore { + constructor( + private readonly _callback: (id: number, data: string) => void, + @ITerminalService private readonly _terminalService: ITerminalService + ) { + super(); + this._terminalService.terminalInstances.forEach(instance => this._register(instance)); + this.add(this._terminalService.onInstanceCreated(instance => this._register(instance))); + } + + private _register(instance: ITerminalInstance): void { + this.add(instance.onData(e => this._callback(instance.id, e))); + } +} diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 65c477284e5..d5cd75bf901 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -400,7 +400,10 @@ export interface MainThreadTerminalServiceShape extends IDisposable { $hide(terminalId: number): void; $sendText(terminalId: number, text: string, addNewLine: boolean): void; $show(terminalId: number, preserveFocus: boolean): void; + /** @deprecated */ $registerOnDataListener(terminalId: number): void; + $startSendingDataEvents(): void; + $stopSendingDataEvents(): void; // Process $sendProcessTitle(terminalId: number, title: string): void; @@ -1157,7 +1160,9 @@ export interface ExtHostTerminalServiceShape { $acceptTerminalOpened(id: number, name: string): void; $acceptActiveTerminalChanged(id: number | null): void; $acceptTerminalProcessId(id: number, processId: number): void; + /** @deprecated */ $acceptTerminalProcessData(id: number, data: string): void; + $acceptTerminalProcessData2(id: number, data: string): void; $acceptTerminalRendererInput(id: number, data: string): void; $acceptTerminalTitleChange(id: number, name: string): void; $acceptTerminalDimensions(id: number, cols: number, rows: number): void; diff --git a/src/vs/workbench/api/node/extHost.api.impl.ts b/src/vs/workbench/api/node/extHost.api.impl.ts index 3c4e7ee8a66..16a56dbf4cf 100644 --- a/src/vs/workbench/api/node/extHost.api.impl.ts +++ b/src/vs/workbench/api/node/extHost.api.impl.ts @@ -457,8 +457,13 @@ export function createApiFactory( return extHostTerminalService.onDidChangeActiveTerminal(listener, thisArg, disposables); }, onDidChangeTerminalDimensions(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension); return extHostTerminalService.onDidChangeTerminalDimensions(listener, thisArg, disposables); }, + onDidWriteTerminalData(listener, thisArg?, disposables?) { + checkProposedApiEnabled(extension); + return extHostTerminalService.onDidWriteTerminalData(listener, thisArg, disposables); + }, get state() { return extHostWindow.state; }, diff --git a/src/vs/workbench/api/node/extHostTask.ts b/src/vs/workbench/api/node/extHostTask.ts index 5c6a6a2c2f3..6c70ab2cde6 100644 --- a/src/vs/workbench/api/node/extHostTask.ts +++ b/src/vs/workbench/api/node/extHostTask.ts @@ -588,7 +588,7 @@ export class ExtHostTask implements ExtHostTaskShape { // Clone the custom execution to keep the original untouched. This is important for multiple runs of the same task. this._activeCustomExecutions2.set(execution.id, execution2); - await this._terminalService.attachPtyToTerminal(terminalId, await execution2.callback()); + this._terminalService.attachPtyToTerminal(terminalId, await execution2.callback()); } // Once a terminal is spun up for the custom execution task this event will be fired. diff --git a/src/vs/workbench/api/node/extHostTerminalService.ts b/src/vs/workbench/api/node/extHostTerminalService.ts index 4462de7b458..f2d58a8e651 100644 --- a/src/vs/workbench/api/node/extHostTerminalService.ts +++ b/src/vs/workbench/api/node/extHostTerminalService.ts @@ -88,7 +88,9 @@ export class ExtHostTerminal extends BaseExtHostTerminal implements vscode.Termi private _pidPromiseComplete: ((value: number | undefined) => any) | undefined; private _rows: number | undefined; + /** @deprecated */ private readonly _onData = new Emitter(); + /** @deprecated */ public get onDidWriteData(): Event { // Tell the main side to start sending data if it's not already this._idPromise.then(id => { @@ -97,6 +99,8 @@ export class ExtHostTerminal extends BaseExtHostTerminal implements vscode.Termi return this._onData.event; } + public isOpen: boolean = false; + constructor( proxy: MainThreadTerminalServiceShape, private _name?: string, @@ -305,6 +309,8 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { public get onDidChangeActiveTerminal(): Event { return this._onDidChangeActiveTerminal && this._onDidChangeActiveTerminal.event; } private readonly _onDidChangeTerminalDimensions: Emitter = new Emitter(); public get onDidChangeTerminalDimensions(): Event { return this._onDidChangeTerminalDimensions && this._onDidChangeTerminalDimensions.event; } + private readonly _onDidWriteTerminalData: Emitter; + public get onDidWriteTerminalData(): Event { return this._onDidWriteTerminalData && this._onDidWriteTerminalData.event; } constructor( mainContext: IMainContext, @@ -314,6 +320,10 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { private _logService: ILogService ) { this._proxy = mainContext.getProxy(MainContext.MainThreadTerminalService); + this._onDidWriteTerminalData = new Emitter({ + onFirstListenerAdd: () => this._proxy.$startSendingDataEvents(), + onLastListenerRemove: () => this._proxy.$stopSendingDataEvents() + }); this._updateLastActiveWorkspace(); this._updateVariableResolver(); this._registerListeners(); @@ -341,7 +351,7 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { return terminal; } - public async attachPtyToTerminal(id: number, pty: vscode.Pseudoterminal): Promise { + public attachPtyToTerminal(id: number, pty: vscode.Pseudoterminal): void { const terminal = this._getTerminalByIdEventually(id); if (!terminal) { throw new Error(`Cannot resolve terminal with id ${id} for virtual process`); @@ -409,7 +419,7 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { return renderer; } - public $acceptActiveTerminalChanged(id: number | null): void { + public async $acceptActiveTerminalChanged(id: number | null): Promise { const original = this._activeTerminal; if (id === null) { this._activeTerminal = undefined; @@ -418,38 +428,45 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { } return; } - this.performTerminalIdAction(id, terminal => { - if (terminal) { - this._activeTerminal = terminal; - if (original !== this._activeTerminal) { - this._onDidChangeActiveTerminal.fire(this._activeTerminal); - } + const terminal = await this._getTerminalByIdEventually(id); + if (terminal) { + this._activeTerminal = terminal; + if (original !== this._activeTerminal) { + this._onDidChangeActiveTerminal.fire(this._activeTerminal); } - }); + } } - public $acceptTerminalProcessData(id: number, data: string): void { - this._getTerminalByIdEventually(id).then(terminal => { - if (terminal) { - terminal._fireOnData(data); - } - }); + /** @deprecated */ + public async $acceptTerminalProcessData(id: number, data: string): Promise { + const terminal = await this._getTerminalByIdEventually(id); + if (terminal) { + terminal._fireOnData(data); + } } - public $acceptTerminalDimensions(id: number, cols: number, rows: number): void { - this._getTerminalByIdEventually(id).then(terminal => { - if (terminal) { - if (terminal.setDimensions(cols, rows)) { - this._onDidChangeTerminalDimensions.fire({ - terminal: terminal, - dimensions: terminal.dimensions as vscode.TerminalDimensions - }); - } - } - }); + public async $acceptTerminalProcessData2(id: number, data: string): Promise { + const terminal = await this._getTerminalByIdEventually(id); + if (terminal) { + this._onDidWriteTerminalData.fire({ terminal, data }); + } } - public $acceptTerminalMaximumDimensions(id: number, cols: number, rows: number): void { + public async $acceptTerminalDimensions(id: number, cols: number, rows: number): Promise { + const terminal = await this._getTerminalByIdEventually(id); + if (terminal) { + if (terminal.setDimensions(cols, rows)) { + this._onDidChangeTerminalDimensions.fire({ + terminal: terminal, + dimensions: terminal.dimensions as vscode.TerminalDimensions + }); + } + } + } + + public async $acceptTerminalMaximumDimensions(id: number, cols: number, rows: number): Promise { + await this._getTerminalByIdEventually(id); + if (this._terminalProcesses[id]) { // Virtual processes only - when virtual process resize fires it means that the // terminal's maximum dimensions changed @@ -473,14 +490,16 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { } } - public $acceptTerminalTitleChange(id: number, name: string): void { + public async $acceptTerminalTitleChange(id: number, name: string): Promise { + await this._getTerminalByIdEventually(id); const extHostTerminal = this._getTerminalObjectById(this.terminals, id); if (extHostTerminal) { extHostTerminal.name = name; } } - public $acceptTerminalClosed(id: number): void { + public async $acceptTerminalClosed(id: number): Promise { + await this._getTerminalByIdEventually(id); const index = this._getTerminalObjectIndexById(this.terminals, id); if (index !== null) { const terminal = this._terminals.splice(index, 1)[0]; @@ -493,6 +512,7 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { if (index !== null) { // The terminal has already been created (via createTerminal*), only fire the event this._onDidOpenTerminal.fire(this.terminals[index]); + this.terminals[index].isOpen = true; return; } @@ -500,13 +520,18 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { const terminal = new ExtHostTerminal(this._proxy, name, id, renderer ? RENDERER_NO_PROCESS_ID : undefined); this._terminals.push(terminal); this._onDidOpenTerminal.fire(terminal); + terminal.isOpen = true; } - public $acceptTerminalProcessId(id: number, processId: number): void { - this.performTerminalIdAction(id, terminal => terminal._setProcessId(processId)); + public async $acceptTerminalProcessId(id: number, processId: number): Promise { + const terminal = await this._getTerminalByIdEventually(id); + if (terminal) { + terminal._setProcessId(processId); + } } public performTerminalIdAction(id: number, callback: (terminal: ExtHostTerminal) => void): void { + // TODO: Use await this._getTerminalByIdEventually(id); let terminal = this._getTerminalById(id); if (terminal) { callback(terminal); @@ -629,7 +654,27 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { public async $startExtensionTerminal(id: number, initialDimensions: ITerminalDimensionsDto | undefined): Promise { // Make sure the ExtHostTerminal exists so onDidOpenTerminal has fired before we call // Pseudoterminal.start - await this._getTerminalByIdEventually(id); + const terminal = await this._getTerminalByIdEventually(id); + if (!terminal) { + return; + } + + // Wait for onDidOpenTerminal to fire + let openPromise: Promise; + if (terminal.isOpen) { + openPromise = Promise.resolve(); + } else { + openPromise = new Promise(r => { + // Ensure open is called after onDidOpenTerminal + const listener = this.onDidOpenTerminal(async e => { + if (e === terminal) { + listener.dispose(); + r(); + } + }); + }); + } + await openPromise; // Processes should be initialized here for normal virtual process terminals, however for // tasks they are responsible for attaching the virtual process to a terminal so this @@ -706,7 +751,8 @@ export class ExtHostTerminalService implements ExtHostTerminalServiceShape { this._proxy.$sendProcessExit(id, exitCode); } - private _getTerminalByIdEventually(id: number, retries: number = 5): Promise { + // TODO: This could be improved by using a single promise and resolve it when the terminal is ready + private _getTerminalByIdEventually(id: number, retries: number = 5): Promise { if (!this._getTerminalPromises[id]) { this._getTerminalPromises[id] = this._createGetTerminalPromise(id, retries); } else {