Skip to content

Modify Run Selection/Line to reuse existing terminal #25178

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
252 changes: 252 additions & 0 deletions src/client/common/terminal/externalTerminalService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { inject, injectable } from 'inversify';
import { CancellationToken, Disposable, Event, EventEmitter, Terminal, TerminalShellExecution, window } from 'vscode';
import '../../common/extensions';
import { IInterpreterService } from '../../interpreter/contracts';
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 { _SCRIPTS_DIR } from '../process/internal/scripts/constants';
import { IConfigurationService, IDisposableRegistry } from '../types';
import {
ITerminalActivator,
ITerminalHelper,
ITerminalService,
TerminalCreationOptions,
TerminalShellType,
} from './types';
import { traceVerbose } from '../../logging';
import { getConfiguration } from '../vscodeApis/workspaceApis';
import { useEnvExtension } from '../../envExt/api.internal';
import { ensureTerminalLegacy } from '../../envExt/api.legacy';
import { sleep } from '../utils/async';
import { isWindows } from '../utils/platform';
import { getPythonMinorVersion } from '../../repl/replUtils';
import { PythonEnvironment } from '../../pythonEnvironments/info';

@injectable()
export class ExternalTerminalService implements ITerminalService, Disposable {
private terminal?: Terminal;
private ownsTerminal: boolean = false;
private terminalShellType!: TerminalShellType;
private terminalClosed = new EventEmitter<void>();
private terminalManager: ITerminalManager;
private terminalHelper: ITerminalHelper;
private terminalActivator: ITerminalActivator;
private terminalAutoActivator: ITerminalAutoActivation;
private readonly executeCommandListeners: Set<Disposable> = new Set();
private _terminalFirstLaunched: boolean = true;
public get onDidCloseTerminal(): Event<void> {
return this.terminalClosed.event.bind(this.terminalClosed);
}

constructor(
@inject(IServiceContainer) private serviceContainer: IServiceContainer,
private readonly options?: TerminalCreationOptions,
) {
const disposableRegistry = this.serviceContainer.get<Disposable[]>(IDisposableRegistry);
disposableRegistry.push(this);
this.terminalHelper = this.serviceContainer.get<ITerminalHelper>(ITerminalHelper);
this.terminalManager = this.serviceContainer.get<ITerminalManager>(ITerminalManager);
this.terminalAutoActivator = this.serviceContainer.get<ITerminalAutoActivation>(ITerminalAutoActivation);
this.terminalManager.onDidCloseTerminal(this.terminalCloseHandler, this, disposableRegistry);
this.terminalActivator = this.serviceContainer.get<ITerminalActivator>(ITerminalActivator);
}
public dispose() {
if (this.ownsTerminal) {
this.terminal?.dispose();
}

if (this.executeCommandListeners && this.executeCommandListeners.size > 0) {
this.executeCommandListeners.forEach((d) => {
d?.dispose();
});
}
}
public async sendCommand(command: string, args: string[], _?: CancellationToken): Promise<void> {
await this.ensureTerminal();
const text = this.terminalHelper.buildCommandForTerminal(this.terminalShellType, command, args);
if (!this.options?.hideFromUser) {
this.terminal!.show(true);
}

await this.executeCommand(text, false);
}

/** @deprecated */
public async sendText(text: string): Promise<void> {
await this.ensureTerminal();
if (!this.options?.hideFromUser) {
this.terminal!.show(true);
}
this.terminal!.sendText(text);
this.terminal = undefined;
}

public async executeCommand(
commandLine: string,
isPythonShell: boolean,
): Promise<TerminalShellExecution | undefined> {
const terminal = window.activeTerminal!;
if (!this.options?.hideFromUser) {
terminal.show(true);
}

// If terminal was just launched, wait some time for shell integration to onDidChangeShellIntegration.
if (!terminal.shellIntegration && this._terminalFirstLaunched) {
this._terminalFirstLaunched = false;
const promise = new Promise<boolean>((resolve) => {
const disposable = this.terminalManager.onDidChangeTerminalShellIntegration(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
clearTimeout(timer);
disposable.dispose();
resolve(true);
});
const TIMEOUT_DURATION = 500;
const timer = setTimeout(() => {
disposable.dispose();
resolve(true);
}, TIMEOUT_DURATION);
});
await promise;
}

const config = getConfiguration('python');
const pythonrcSetting = config.get<boolean>('terminal.shellIntegration.enabled');

const minorVersion = this.options?.resource
? await getPythonMinorVersion(
this.options.resource,
this.serviceContainer.get<IInterpreterService>(IInterpreterService),
)
: undefined;

if ((isPythonShell && !pythonrcSetting) || (isPythonShell && isWindows()) || (minorVersion ?? 0) >= 13) {
// If user has explicitly disabled SI for Python, use sendText for inside Terminal REPL.
terminal.sendText(commandLine);
return undefined;
} else if (terminal.shellIntegration) {
const execution = terminal.shellIntegration.executeCommand(commandLine);
traceVerbose(`Shell Integration is enabled, executeCommand: ${commandLine}`);
return execution;
} else {
terminal.sendText(commandLine);
traceVerbose(`Shell Integration is disabled, sendText: ${commandLine}`);
}

this.terminal = undefined;
return undefined;
}

public async show(preserveFocus: boolean = true): Promise<void> {
await this.ensureTerminal(preserveFocus);
if (!this.options?.hideFromUser) {
this.terminal!.show(preserveFocus);
}
this.terminal = undefined;
}

private resolveInterpreterPath(
interpreter: PythonEnvironment | undefined,
settingsPythonPath: string | undefined,
): string {
if (interpreter) {
if ('path' in interpreter && interpreter.path) {
return interpreter.path;
}
const uriFsPath = (interpreter as any).uri?.fsPath as string | undefined;
if (uriFsPath) {
return uriFsPath;
}
}
return settingsPythonPath ?? 'python';
}

private runPythonReplInActiveTerminal() {
const settings = this.serviceContainer
.get<IConfigurationService>(IConfigurationService)
.getSettings(this.options?.resource);
const interpreterPath = this.resolveInterpreterPath(this.options?.interpreter, settings.pythonPath);
this.terminalShellType = this.terminalHelper.identifyTerminalShell(this.terminal);
const launchCmd = this.terminalHelper.buildCommandForTerminal(this.terminalShellType, interpreterPath, []);
this.terminal!.sendText(launchCmd);
}

// TODO: Debt switch to Promise<Terminal> ---> breaks 20 tests
public async ensureTerminal(preserveFocus: boolean = true): Promise<void> {
this.terminal = window.activeTerminal;
if (this.terminal) {
this.ownsTerminal = false;
if (this.terminal.state.shell !== 'python') {
this.runPythonReplInActiveTerminal();
}
return;
}

if (useEnvExtension()) {
this.terminal = await ensureTerminalLegacy(this.options?.resource, {
name: this.options?.title || 'Python',
hideFromUser: this.options?.hideFromUser,
});
this.ownsTerminal = true;
} else {
this.terminalShellType = this.terminalHelper.identifyTerminalShell(this.terminal);
this.terminal = this.terminalManager.createTerminal({
name: this.options?.title || 'Python',
hideFromUser: this.options?.hideFromUser,
});
this.ownsTerminal = true;
this.terminalAutoActivator.disableAutoActivation(this.terminal);

await sleep(100);

await this.terminalActivator.activateEnvironmentInTerminal(this.terminal, {
resource: this.options?.resource,
preserveFocus,
interpreter: this.options?.interpreter,
hideFromUser: this.options?.hideFromUser,
});
}

if (!this.options?.hideFromUser) {
this.terminal.show(preserveFocus);
}

this.sendTelemetry().ignoreErrors();
return;
}

private terminalCloseHandler(terminal: Terminal) {
if (terminal === this.terminal) {
this.terminalClosed.fire();
this.terminal = undefined;
this.ownsTerminal = false;
}
}

private async sendTelemetry() {
const pythonPath = this.serviceContainer
.get<IConfigurationService>(IConfigurationService)
.getSettings(this.options?.resource).pythonPath;
const interpreterInfo =
this.options?.interpreter ||
(await this.serviceContainer
.get<IInterpreterService>(IInterpreterService)
.getInterpreterDetails(pythonPath));
const pythonVersion = interpreterInfo && interpreterInfo.version ? interpreterInfo.version.raw : undefined;
const interpreterType = interpreterInfo ? interpreterInfo.envType : undefined;
captureTelemetry(EventName.TERMINAL_CREATE, {
terminal: this.terminalShellType,
pythonVersion,
interpreterType,
});
}

public hasActiveTerminal(): boolean {
return !!window.activeTerminal;
}
}
9 changes: 6 additions & 3 deletions src/client/common/terminal/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { IFileSystem } from '../platform/types';
import { TerminalService } from './service';
import { SynchronousTerminalService } from './syncTerminalService';
import { ITerminalService, ITerminalServiceFactory, TerminalCreationOptions } from './types';
import { ExternalTerminalService } from './externalTerminalService';

@injectable()
export class TerminalServiceFactory implements ITerminalServiceFactory {
private terminalServices: Map<string, TerminalService>;
private terminalServices: Map<string, TerminalService | ExternalTerminalService>;

constructor(
@inject(IServiceContainer) private serviceContainer: IServiceContainer,
Expand All @@ -35,7 +36,8 @@ export class TerminalServiceFactory implements ITerminalServiceFactory {
terminalTitle = `${terminalTitle}: ${path.basename(resource.fsPath).replace('.py', '')}`;
}
options.title = terminalTitle;
const terminalService = new TerminalService(this.serviceContainer, options);
const terminalService = new ExternalTerminalService(this.serviceContainer, options);
// const terminalService = new TerminalService(this.serviceContainer, options);
this.terminalServices.set(id, terminalService);
}

Expand All @@ -49,7 +51,8 @@ export class TerminalServiceFactory implements ITerminalServiceFactory {
}
public createTerminalService(resource?: Uri, title?: string): ITerminalService {
title = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python';
return new TerminalService(this.serviceContainer, { resource, title });
return new ExternalTerminalService(this.serviceContainer, { resource, title });
// return new TerminalService(this.serviceContainer, { resource, title });
}
private getTerminalId(
title: string,
Expand Down
4 changes: 4 additions & 0 deletions src/client/common/terminal/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,8 @@ export class TerminalService implements ITerminalService, Disposable {
interpreterType,
});
}

public hasActiveTerminal(): boolean {
return !!this.terminal;
}
}
7 changes: 6 additions & 1 deletion src/client/common/terminal/syncTerminalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createDeferred, Deferred } from '../utils/async';
import { noop } from '../utils/misc';
import { TerminalService } from './service';
import { ITerminalService } from './types';
import { ExternalTerminalService } from './externalTerminalService';

enum State {
notStarted = 0,
Expand Down Expand Up @@ -101,7 +102,7 @@ export class SynchronousTerminalService implements ITerminalService, Disposable
constructor(
@inject(IFileSystem) private readonly fs: IFileSystem,
@inject(IInterpreterService) private readonly interpreter: IInterpreterService,
public readonly terminalService: TerminalService,
public readonly terminalService: TerminalService | ExternalTerminalService,
private readonly pythonInterpreter?: PythonEnvironment,
) {}
public dispose() {
Expand Down Expand Up @@ -158,4 +159,8 @@ export class SynchronousTerminalService implements ITerminalService, Disposable
return l;
});
}

public hasActiveTerminal(): boolean {
return this.terminalService.hasActiveTerminal();
}
}
1 change: 1 addition & 0 deletions src/client/common/terminal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface ITerminalService extends IDisposable {
sendText(text: string): Promise<void>;
executeCommand(commandLine: string, isPythonShell: boolean): Promise<TerminalShellExecution | undefined>;
show(preserveFocus?: boolean): Promise<void>;
hasActiveTerminal(): boolean;
}

export const ITerminalServiceFactory = Symbol('ITerminalServiceFactory');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {

public async initializeRepl(resource: Resource) {
const terminalService = this.getTerminalService(resource);
if (this.replActive && (await this.replActive)) {
if (terminalService.hasActiveTerminal()) {
await terminalService.show();
return;
}
Expand Down