Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/client/common/application/terminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export class TerminalManager implements ITerminalManager {
public onDidEndTerminalShellExecution(handler: (e: TerminalShellExecutionEndEvent) => void): Disposable {
return window.onDidEndTerminalShellExecution(handler);
}
public onDidChangeTerminalState(handler: (e: Terminal) => void): Disposable {
return window.onDidChangeTerminalState(handler);
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/client/common/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,8 @@ export interface ITerminalManager {
onDidChangeTerminalShellIntegration(handler: (e: TerminalShellIntegrationChangeEvent) => void): Disposable;

onDidEndTerminalShellExecution(handler: (e: TerminalShellExecutionEndEvent) => void): Disposable;

onDidChangeTerminalState(handler: (e: Terminal) => void): Disposable;
}

export const IDebugService = Symbol('IDebugManager');
Expand Down
102 changes: 94 additions & 8 deletions src/client/common/terminal/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { IServiceContainer } from '../../ioc/types';
import { captureTelemetry } from '../../telemetry';
import { EventName } from '../../telemetry/constants';
import { ITerminalAutoActivation } from '../../terminals/types';
import { ITerminalManager } from '../application/types';
import { IApplicationShell, ITerminalManager } from '../application/types';
import { _SCRIPTS_DIR } from '../process/internal/scripts/constants';
import { IConfigurationService, IDisposableRegistry } from '../types';
import {
Expand All @@ -20,9 +20,9 @@ import {
TerminalShellType,
} from './types';
import { traceVerbose } from '../../logging';
import { sleep } from '../utils/async';
import { useEnvExtension } from '../../envExt/api.internal';
import { ensureTerminalLegacy } from '../../envExt/api.legacy';
import { sleep } from '../utils/async';

@injectable()
export class TerminalService implements ITerminalService, Disposable {
Expand All @@ -33,8 +33,13 @@ export class TerminalService implements ITerminalService, Disposable {
private terminalHelper: ITerminalHelper;
private terminalActivator: ITerminalActivator;
private terminalAutoActivator: ITerminalAutoActivation;
private applicationShell: IApplicationShell;
private readonly executeCommandListeners: Set<Disposable> = new Set();
private _terminalFirstLaunched: boolean = true;
private pythonReplCommandQueue: string[] = [];
private isReplReady: boolean = false;
private replPromptListener?: Disposable;
private replShellTypeListener?: Disposable;
public get onDidCloseTerminal(): Event<void> {
return this.terminalClosed.event.bind(this.terminalClosed);
}
Expand All @@ -48,11 +53,13 @@ export class TerminalService implements ITerminalService, Disposable {
this.terminalHelper = this.serviceContainer.get<ITerminalHelper>(ITerminalHelper);
this.terminalManager = this.serviceContainer.get<ITerminalManager>(ITerminalManager);
this.terminalAutoActivator = this.serviceContainer.get<ITerminalAutoActivation>(ITerminalAutoActivation);
this.applicationShell = this.serviceContainer.get<IApplicationShell>(IApplicationShell);
this.terminalManager.onDidCloseTerminal(this.terminalCloseHandler, this, disposableRegistry);
this.terminalActivator = this.serviceContainer.get<ITerminalActivator>(ITerminalActivator);
}
public dispose() {
this.terminal?.dispose();
this.disposeReplListener();

if (this.executeCommandListeners && this.executeCommandListeners.size > 0) {
this.executeCommandListeners.forEach((d) => {
Expand Down Expand Up @@ -81,7 +88,86 @@ export class TerminalService implements ITerminalService, Disposable {
commandLine: string,
isPythonShell: boolean,
): Promise<TerminalShellExecution | undefined> {
const terminal = this.terminal!;
if (isPythonShell) {
if (this.isReplReady) {
this.terminal?.sendText(commandLine);
traceVerbose(`Python REPL sendText: ${commandLine}`);
} else {
// Queue command to run once REPL is ready.
this.pythonReplCommandQueue.push(commandLine);
traceVerbose(`Python REPL queued command: ${commandLine}`);
this.startReplListener();
}
return undefined;
}

// Non-REPL code execution
return this.executeCommandInternal(commandLine);
}

private startReplListener(): void {
if (this.replPromptListener || this.replShellTypeListener) {
return;
}

this.replShellTypeListener = this.terminalManager.onDidChangeTerminalState((terminal) => {
if (this.terminal && terminal === this.terminal) {
if (terminal.state.shell == 'python') {
traceVerbose('Python REPL ready from terminal shell api');
this.onReplReady();
}
}
});

let terminalData = '';
this.replPromptListener = this.applicationShell.onDidWriteTerminalData((e) => {
if (this.terminal && e.terminal === this.terminal) {
terminalData += e.data;
if (/>>>\s*$/.test(terminalData)) {
traceVerbose('Python REPL ready, from >>> prompt detection');
this.onReplReady();
}
}
});
}

private onReplReady(): void {
if (this.isReplReady) {
return;
}
this.isReplReady = true;
this.flushReplQueue();
this.disposeReplListener();
}

private disposeReplListener(): void {
if (this.replPromptListener) {
this.replPromptListener.dispose();
this.replPromptListener = undefined;
}
if (this.replShellTypeListener) {
this.replShellTypeListener.dispose();
this.replShellTypeListener = undefined;
}
}

private flushReplQueue(): void {
while (this.pythonReplCommandQueue.length > 0) {
const commandLine = this.pythonReplCommandQueue.shift();
if (commandLine) {
traceVerbose(`Executing queued REPL command: ${commandLine}`);
this.terminal?.sendText(commandLine);
}
}
}

private async executeCommandInternal(commandLine: string): Promise<TerminalShellExecution | undefined> {
const terminal = this.terminal;
if (!terminal) {
traceVerbose('Terminal not available, cannot execute command');
return undefined;
}

if (!this.options?.hideFromUser) {
terminal.show(true);
}
Expand All @@ -105,11 +191,7 @@ export class TerminalService implements ITerminalService, Disposable {
await promise;
}

if (isPythonShell) {
// Prevent KeyboardInterrupt in Python REPL: https://github.com/microsoft/vscode-python/issues/25468
terminal.sendText(commandLine);
traceVerbose(`Python REPL detected, sendText: ${commandLine}`);
} else if (terminal.shellIntegration) {
if (terminal.shellIntegration) {
const execution = terminal.shellIntegration.executeCommand(commandLine);
traceVerbose(`Shell Integration is enabled, executeCommand: ${commandLine}`);
return execution;
Expand Down Expand Up @@ -138,6 +220,7 @@ export class TerminalService implements ITerminalService, Disposable {
name: this.options?.title || 'Python',
hideFromUser: this.options?.hideFromUser,
});
return;
} else {
this.terminalShellType = this.terminalHelper.identifyTerminalShell(this.terminal);
this.terminal = this.terminalManager.createTerminal({
Expand Down Expand Up @@ -167,6 +250,9 @@ export class TerminalService implements ITerminalService, Disposable {
if (terminal === this.terminal) {
this.terminalClosed.fire();
this.terminal = undefined;
this.isReplReady = false;
this.disposeReplListener();
this.pythonReplCommandQueue = [];
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/client/common/vscodeApis/windowApis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
NotebookDocument,
NotebookEditor,
NotebookDocumentShowOptions,
Terminal,
} from 'vscode';
import { createDeferred, Deferred } from '../utils/async';
import { Resource } from '../types';
Expand Down Expand Up @@ -124,6 +125,10 @@ export function onDidStartTerminalShellExecution(handler: (e: TerminalShellExecu
return window.onDidStartTerminalShellExecution(handler);
}

export function onDidChangeTerminalState(handler: (e: Terminal) => void): Disposable {
return window.onDidChangeTerminalState(handler);
}

export enum MultiStepAction {
Back = 'Back',
Cancel = 'Cancel',
Expand Down
69 changes: 59 additions & 10 deletions src/test/common/terminals/service.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
Uri,
Terminal as VSCodeTerminal,
WorkspaceConfiguration,
TerminalDataWriteEvent,
} from 'vscode';
import { ITerminalManager, IWorkspaceService } from '../../../client/common/application/types';
import { IApplicationShell, ITerminalManager, IWorkspaceService } from '../../../client/common/application/types';
import { EXTENSION_ROOT_DIR } from '../../../client/common/constants';
import { IPlatformService } from '../../../client/common/platform/types';
import { TerminalService } from '../../../client/common/terminal/service';
Expand Down Expand Up @@ -56,6 +57,9 @@ suite('Terminal Service', () => {
let useEnvExtensionStub: sinon.SinonStub;
let interpreterService: TypeMoq.IMock<IInterpreterService>;
let options: TypeMoq.IMock<TerminalCreationOptions>;
let applicationShell: TypeMoq.IMock<IApplicationShell>;
let onDidWriteTerminalDataEmitter: EventEmitter<TerminalDataWriteEvent>;
let onDidChangeTerminalStateEmitter: EventEmitter<VSCodeTerminal>;

setup(() => {
useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension');
Expand Down Expand Up @@ -118,6 +122,17 @@ suite('Terminal Service', () => {
mockServiceContainer.setup((c) => c.get(ITerminalActivator)).returns(() => terminalActivator.object);
mockServiceContainer.setup((c) => c.get(ITerminalAutoActivation)).returns(() => terminalAutoActivator.object);
mockServiceContainer.setup((c) => c.get(IInterpreterService)).returns(() => interpreterService.object);

applicationShell = TypeMoq.Mock.ofType<IApplicationShell>();
onDidWriteTerminalDataEmitter = new EventEmitter<TerminalDataWriteEvent>();
applicationShell.setup((a) => a.onDidWriteTerminalData).returns(() => onDidWriteTerminalDataEmitter.event);
mockServiceContainer.setup((c) => c.get(IApplicationShell)).returns(() => applicationShell.object);

onDidChangeTerminalStateEmitter = new EventEmitter<VSCodeTerminal>();
terminalManager
.setup((t) => t.onDidChangeTerminalState(TypeMoq.It.isAny()))
.returns((handler) => onDidChangeTerminalStateEmitter.event(handler));

getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration');
isWindowsStub = sinon.stub(platform, 'isWindows');
pythonConfig = TypeMoq.Mock.ofType<WorkspaceConfiguration>();
Expand Down Expand Up @@ -230,8 +245,10 @@ suite('Terminal Service', () => {
terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash);
terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object);

service.ensureTerminal();
service.executeCommand(textToSend, true);
await service.ensureTerminal();
const executePromise = service.executeCommand(textToSend, true);
onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' });
await executePromise;

terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1));
terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1));
Expand All @@ -251,8 +268,10 @@ suite('Terminal Service', () => {
terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash);
terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object);

service.ensureTerminal();
service.executeCommand(textToSend, true);
await service.ensureTerminal();
const executePromise = service.executeCommand(textToSend, true);
onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' });
await executePromise;

terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1));
terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1));
Expand All @@ -273,8 +292,10 @@ suite('Terminal Service', () => {
terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash);
terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object);

service.ensureTerminal();
service.executeCommand(textToSend, true);
await service.ensureTerminal();
const executePromise = service.executeCommand(textToSend, true);
onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' });
await executePromise;

terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1));
terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1));
Expand Down Expand Up @@ -305,7 +326,9 @@ suite('Terminal Service', () => {
terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object);

await service.ensureTerminal();
await service.executeCommand(textToSend, true);
const executePromise = service.executeCommand(textToSend, true);
onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' });
await executePromise;

terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.once());
});
Expand All @@ -325,13 +348,39 @@ suite('Terminal Service', () => {
terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash);
terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object);

service.ensureTerminal();
service.executeCommand(textToSend, true);
await service.ensureTerminal();
const executePromise = service.executeCommand(textToSend, true);
onDidWriteTerminalDataEmitter.fire({ terminal: terminal.object, data: '>>> ' });
await executePromise;

terminal.verify((t) => t.show(TypeMoq.It.isValue(true)), TypeMoq.Times.exactly(1));
terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1));
});

test('Ensure REPL ready when onDidChangeTerminalState fires with python shell', async () => {
pythonConfig
.setup((p) => p.get('terminal.shellIntegration.enabled'))
.returns(() => false)
.verifiable(TypeMoq.Times.once());

terminalHelper
.setup((helper) => helper.getEnvironmentActivationCommands(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
.returns(() => Promise.resolve(undefined));
service = new TerminalService(mockServiceContainer.object);
const textToSend = 'Some Text';
terminalHelper.setup((h) => h.identifyTerminalShell(TypeMoq.It.isAny())).returns(() => TerminalShellType.bash);

terminal.setup((t) => t.state).returns(() => ({ isInteractedWith: true, shell: 'python' }));
terminalManager.setup((t) => t.createTerminal(TypeMoq.It.isAny())).returns(() => terminal.object);

await service.ensureTerminal();
const executePromise = service.executeCommand(textToSend, true);
onDidChangeTerminalStateEmitter.fire(terminal.object);
await executePromise;

terminal.verify((t) => t.sendText(TypeMoq.It.isValue(textToSend)), TypeMoq.Times.exactly(1));
});

test('Ensure terminal is not shown if `hideFromUser` option is set to `true`', async () => {
terminalHelper
.setup((helper) => helper.getEnvironmentActivationCommands(TypeMoq.It.isAny(), TypeMoq.It.isAny()))
Expand Down
7 changes: 2 additions & 5 deletions src/test/smoke/smartSend.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,8 @@ suite('Smoke Test: Run Smart Selection and Advance Cursor', async () => {
suiteTeardown(closeActiveWindows);
teardown(closeActiveWindows);

// TODO: Re-enable this test once the flakiness on Windows is resolved
test('Smart Send', async function () {
if (process.platform === 'win32') {
return this.skip();
}
// TODO: Re-enable this test once the flakiness on Windows, linux are resolved
test.skip('Smart Send', async function () {
const file = path.join(
EXTENSION_ROOT_DIR_FOR_TESTS,
'src',
Expand Down