diff --git a/src/client/common/terminal/externalTerminalService.ts b/src/client/common/terminal/externalTerminalService.ts new file mode 100644 index 000000000000..8e6b3475f7a9 --- /dev/null +++ b/src/client/common/terminal/externalTerminalService.ts @@ -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(); + private terminalManager: ITerminalManager; + private terminalHelper: ITerminalHelper; + private terminalActivator: ITerminalActivator; + private terminalAutoActivator: ITerminalAutoActivation; + private readonly executeCommandListeners: Set = new Set(); + private _terminalFirstLaunched: boolean = true; + public get onDidCloseTerminal(): Event { + return this.terminalClosed.event.bind(this.terminalClosed); + } + + constructor( + @inject(IServiceContainer) private serviceContainer: IServiceContainer, + private readonly options?: TerminalCreationOptions, + ) { + const disposableRegistry = this.serviceContainer.get(IDisposableRegistry); + disposableRegistry.push(this); + this.terminalHelper = this.serviceContainer.get(ITerminalHelper); + this.terminalManager = this.serviceContainer.get(ITerminalManager); + this.terminalAutoActivator = this.serviceContainer.get(ITerminalAutoActivation); + this.terminalManager.onDidCloseTerminal(this.terminalCloseHandler, this, disposableRegistry); + this.terminalActivator = this.serviceContainer.get(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 { + 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 { + 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 { + 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((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('terminal.shellIntegration.enabled'); + + const minorVersion = this.options?.resource + ? await getPythonMinorVersion( + this.options.resource, + this.serviceContainer.get(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 { + 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) + .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 ---> breaks 20 tests + public async ensureTerminal(preserveFocus: boolean = true): Promise { + 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) + .getSettings(this.options?.resource).pythonPath; + const interpreterInfo = + this.options?.interpreter || + (await this.serviceContainer + .get(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; + } +} diff --git a/src/client/common/terminal/factory.ts b/src/client/common/terminal/factory.ts index 39cc88c4b024..882200d9475c 100644 --- a/src/client/common/terminal/factory.ts +++ b/src/client/common/terminal/factory.ts @@ -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; + private terminalServices: Map; constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, @@ -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); } @@ -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, diff --git a/src/client/common/terminal/service.ts b/src/client/common/terminal/service.ts index e92fbd3d494f..cfdbc1b49008 100644 --- a/src/client/common/terminal/service.ts +++ b/src/client/common/terminal/service.ts @@ -200,4 +200,8 @@ export class TerminalService implements ITerminalService, Disposable { interpreterType, }); } + + public hasActiveTerminal(): boolean { + return !!this.terminal; + } } diff --git a/src/client/common/terminal/syncTerminalService.ts b/src/client/common/terminal/syncTerminalService.ts index 0b46a86ee51e..05e77e9c7a50 100644 --- a/src/client/common/terminal/syncTerminalService.ts +++ b/src/client/common/terminal/syncTerminalService.ts @@ -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, @@ -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() { @@ -158,4 +159,8 @@ export class SynchronousTerminalService implements ITerminalService, Disposable return l; }); } + + public hasActiveTerminal(): boolean { + return this.terminalService.hasActiveTerminal(); + } } diff --git a/src/client/common/terminal/types.ts b/src/client/common/terminal/types.ts index 3e54458a57fd..1fed66d46705 100644 --- a/src/client/common/terminal/types.ts +++ b/src/client/common/terminal/types.ts @@ -56,6 +56,7 @@ export interface ITerminalService extends IDisposable { sendText(text: string): Promise; executeCommand(commandLine: string, isPythonShell: boolean): Promise; show(preserveFocus?: boolean): Promise; + hasActiveTerminal(): boolean; } export const ITerminalServiceFactory = Symbol('ITerminalServiceFactory'); diff --git a/src/client/terminals/codeExecution/terminalCodeExecution.ts b/src/client/terminals/codeExecution/terminalCodeExecution.ts index ea444af4d89e..62206d26513f 100644 --- a/src/client/terminals/codeExecution/terminalCodeExecution.ts +++ b/src/client/terminals/codeExecution/terminalCodeExecution.ts @@ -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; }