From 364abbc4866aa7dc00f0564a09e88e119c3ce662 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Fri, 12 Apr 2024 11:46:25 -0700 Subject: [PATCH 1/2] Trigger env creation prompt on `pip install` in terminal with global environment --- package.json | 3 +- src/client/common/experiments/groups.ts | 6 + src/client/common/utils/localize.ts | 4 + src/client/common/vscodeApis/windowApis.ts | 5 + .../creation/common/workspaceSelection.ts | 10 + .../creation/createEnvApi.ts | 5 +- .../creation/createEnvironment.ts | 18 +- .../creation/globalPipInTerminalTrigger.ts | 81 ++++ .../creation/provider/venvCreationProvider.ts | 11 +- .../creation/registrations.ts | 2 + .../pythonEnvironments/creation/types.ts | 7 +- src/client/telemetry/constants.ts | 1 + src/client/telemetry/index.ts | 7 + .../globalPipInTerminalTrigger.unit.test.ts | 353 ++++++++++++++++++ ...ode.proposed.terminalShellIntegration.d.ts | 312 ++++++++++++++++ 15 files changed, 815 insertions(+), 10 deletions(-) create mode 100644 src/client/pythonEnvironments/creation/globalPipInTerminalTrigger.ts create mode 100644 src/test/pythonEnvironments/creation/globalPipInTerminalTrigger.unit.test.ts create mode 100644 types/vscode.proposed.terminalShellIntegration.d.ts diff --git a/package.json b/package.json index 695ff8793f31..96e7b3e204e8 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "quickPickItemTooltip", "terminalDataWriteEvent", "terminalExecuteCommandEvent", - "contribIssueReporter" + "contribIssueReporter", + "terminalShellIntegration" ], "author": { "name": "Microsoft Corporation" diff --git a/src/client/common/experiments/groups.ts b/src/client/common/experiments/groups.ts index d43f376ddc87..81f157751346 100644 --- a/src/client/common/experiments/groups.ts +++ b/src/client/common/experiments/groups.ts @@ -24,3 +24,9 @@ export enum EnableTestAdapterRewrite { export enum RecommendTensobardExtension { experiment = 'pythonRecommendTensorboardExt', } + +// Experiment to enable triggering venv creation when users install with `pip` +// in a global environment +export enum CreateEnvOnPipInstallTrigger { + experiment = 'pythonCreateEnvOnPipInstall', +} diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index b997e168ce3e..89d5348df215 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -523,5 +523,9 @@ export namespace CreateEnv { export const createEnvironment = l10n.t('Create'); export const disableCheck = l10n.t('Disable'); export const disableCheckWorkspace = l10n.t('Disable (Workspace)'); + + export const globalPipInstallTriggerMessage = l10n.t( + 'You may have installed Python packages into your global environment, which can cause conflicts between package versions. Would you like to create a virtual environment to isolate your dependencies?', + ); } } diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts index f2d934d322f9..6ef02df41d1e 100644 --- a/src/client/common/vscodeApis/windowApis.ts +++ b/src/client/common/vscodeApis/windowApis.ts @@ -18,6 +18,7 @@ import { Disposable, QuickPickItemButtonEvent, Uri, + TerminalShellExecutionStartEvent, } from 'vscode'; import { createDeferred, Deferred } from '../utils/async'; @@ -104,6 +105,10 @@ export function onDidChangeActiveTextEditor(handler: (e: TextEditor | undefined) return window.onDidChangeActiveTextEditor(handler); } +export function onDidStartTerminalShellExecution(handler: (e: TerminalShellExecutionStartEvent) => void): Disposable { + return window.onDidStartTerminalShellExecution(handler); +} + export enum MultiStepAction { Back = 'Back', Cancel = 'Cancel', diff --git a/src/client/pythonEnvironments/creation/common/workspaceSelection.ts b/src/client/pythonEnvironments/creation/common/workspaceSelection.ts index 14246eabd768..72d34c62cc7c 100644 --- a/src/client/pythonEnvironments/creation/common/workspaceSelection.ts +++ b/src/client/pythonEnvironments/creation/common/workspaceSelection.ts @@ -32,6 +32,7 @@ async function getWorkspacesForQuickPick(workspaces: readonly WorkspaceFolder[]) export interface PickWorkspaceFolderOptions { allowMultiSelect?: boolean; token?: CancellationToken; + preSelectedWorkspace?: WorkspaceFolder; } export async function pickWorkspaceFolder( @@ -52,6 +53,15 @@ export async function pickWorkspaceFolder( return undefined; } + if (options?.preSelectedWorkspace) { + if (context === MultiStepAction.Back) { + // In this case there is no Quick Pick shown, should just go to previous + throw MultiStepAction.Back; + } + + return options.preSelectedWorkspace; + } + if (workspaces.length === 1) { if (context === MultiStepAction.Back) { // In this case there is no Quick Pick shown, should just go to previous diff --git a/src/client/pythonEnvironments/creation/createEnvApi.ts b/src/client/pythonEnvironments/creation/createEnvApi.ts index 8858fe92de9d..6bc5de8ce4df 100644 --- a/src/client/pythonEnvironments/creation/createEnvApi.ts +++ b/src/client/pythonEnvironments/creation/createEnvApi.ts @@ -20,6 +20,7 @@ import { } from './proposed.createEnvApis'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; +import { CreateEnvironmentOptionsInternal } from './types'; class CreateEnvironmentProviders { private _createEnvProviders: CreateEnvironmentProvider[] = []; @@ -64,7 +65,9 @@ export function registerCreateEnvironmentFeatures( disposables.push( registerCommand( Commands.Create_Environment, - (options?: CreateEnvironmentOptions): Promise => { + ( + options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, + ): Promise => { const providers = _createEnvironmentProviders.getAll(); return handleCreateEnvironmentCommand(providers, options); }, diff --git a/src/client/pythonEnvironments/creation/createEnvironment.ts b/src/client/pythonEnvironments/creation/createEnvironment.ts index f026a1a82ccd..c7c4e84f445c 100644 --- a/src/client/pythonEnvironments/creation/createEnvironment.ts +++ b/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -17,6 +17,7 @@ import { EnvironmentWillCreateEvent, EnvironmentDidCreateEvent, } from './proposed.createEnvApis'; +import { CreateEnvironmentOptionsInternal } from './types'; const onCreateEnvironmentStartedEvent = new EventEmitter(); const onCreateEnvironmentExitedEvent = new EventEmitter(); @@ -55,7 +56,7 @@ export function getCreationEvents(): { async function createEnvironment( provider: CreateEnvironmentProvider, - options: CreateEnvironmentOptions, + options: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, ): Promise { let result: CreateEnvironmentResult | undefined; let err: Error | undefined; @@ -83,7 +84,7 @@ interface CreateEnvironmentProviderQuickPickItem extends QuickPickItem { async function showCreateEnvironmentQuickPick( providers: readonly CreateEnvironmentProvider[], - options?: CreateEnvironmentOptions, + options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, ): Promise { const items: CreateEnvironmentProviderQuickPickItem[] = providers.map((p) => ({ label: p.name, @@ -91,6 +92,13 @@ async function showCreateEnvironmentQuickPick( id: p.id, })); + if (options?.providerId) { + const provider = providers.find((p) => p.id === options.providerId); + if (provider) { + return provider; + } + } + let selectedItem: CreateEnvironmentProviderQuickPickItem | CreateEnvironmentProviderQuickPickItem[] | undefined; if (options?.showBackButton) { @@ -119,7 +127,9 @@ async function showCreateEnvironmentQuickPick( return undefined; } -function getOptionsWithDefaults(options?: CreateEnvironmentOptions): CreateEnvironmentOptions { +function getOptionsWithDefaults( + options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, +): CreateEnvironmentOptions & CreateEnvironmentOptionsInternal { return { installPackages: true, ignoreSourceControl: true, @@ -131,7 +141,7 @@ function getOptionsWithDefaults(options?: CreateEnvironmentOptions): CreateEnvir export async function handleCreateEnvironmentCommand( providers: readonly CreateEnvironmentProvider[], - options?: CreateEnvironmentOptions, + options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, ): Promise { const optionsWithDefaults = getOptionsWithDefaults(options); let selectedProvider: CreateEnvironmentProvider | undefined; diff --git a/src/client/pythonEnvironments/creation/globalPipInTerminalTrigger.ts b/src/client/pythonEnvironments/creation/globalPipInTerminalTrigger.ts new file mode 100644 index 000000000000..dba5b34e882a --- /dev/null +++ b/src/client/pythonEnvironments/creation/globalPipInTerminalTrigger.ts @@ -0,0 +1,81 @@ +import { Disposable, TerminalShellExecutionStartEvent } from 'vscode'; +import { CreateEnvOnPipInstallTrigger } from '../../common/experiments/groups'; +import { inExperiment } from '../common/externalDependencies'; +import { + disableCreateEnvironmentTrigger, + disableWorkspaceCreateEnvironmentTrigger, + isGlobalPythonSelected, + shouldPromptToCreateEnv, +} from './common/createEnvTriggerUtils'; +import { getWorkspaceFolder, getWorkspaceFolders } from '../../common/vscodeApis/workspaceApis'; +import { CreateEnv } from '../../common/utils/localize'; +import { traceError, traceInfo } from '../../logging'; +import { executeCommand } from '../../common/vscodeApis/commandApis'; +import { Commands, PVSC_EXTENSION_ID } from '../../common/constants'; +import { CreateEnvironmentResult } from './proposed.createEnvApis'; +import { onDidStartTerminalShellExecution, showWarningMessage } from '../../common/vscodeApis/windowApis'; +import { sendTelemetryEvent } from '../../telemetry'; +import { EventName } from '../../telemetry/constants'; + +export function registerTriggerForPipInTerminal(disposables: Disposable[]): void { + if (!shouldPromptToCreateEnv() || !inExperiment(CreateEnvOnPipInstallTrigger.experiment)) { + return; + } + + const folders = getWorkspaceFolders(); + if (!folders || folders.length === 0) { + return; + } + + const createEnvironmentTriggered: Map = new Map(); + folders.forEach((workspaceFolder) => { + createEnvironmentTriggered.set(workspaceFolder.uri.fsPath, false); + }); + + disposables.push( + onDidStartTerminalShellExecution(async (e: TerminalShellExecutionStartEvent) => { + const workspaceFolder = getWorkspaceFolder(e.shellIntegration.cwd); + if ( + workspaceFolder && + !createEnvironmentTriggered.get(workspaceFolder.uri.fsPath) && + (await isGlobalPythonSelected(workspaceFolder)) + ) { + if (e.execution.commandLine.isTrusted && e.execution.commandLine.value.startsWith('pip install')) { + createEnvironmentTriggered.set(workspaceFolder.uri.fsPath, true); + sendTelemetryEvent(EventName.ENVIRONMENT_TERMINAL_GLOBAL_PIP); + const selection = await showWarningMessage( + CreateEnv.Trigger.globalPipInstallTriggerMessage, + CreateEnv.Trigger.createEnvironment, + CreateEnv.Trigger.disableCheckWorkspace, + CreateEnv.Trigger.disableCheck, + ); + if (selection === CreateEnv.Trigger.createEnvironment) { + try { + const result: CreateEnvironmentResult = await executeCommand(Commands.Create_Environment, { + workspaceFolder, + providerId: `${PVSC_EXTENSION_ID}:venv`, + }); + if (result.path) { + traceInfo('CreateEnv Trigger - Environment created: ', result.path); + traceInfo( + `CreateEnv Trigger - Running: ${ + result.path + } -m ${e.execution.commandLine.value.trim()}`, + ); + e.shellIntegration.executeCommand( + `${result.path} -m ${e.execution.commandLine.value}`.trim(), + ); + } + } catch (error) { + traceError('CreateEnv Trigger - Error while creating environment: ', error); + } + } else if (selection === CreateEnv.Trigger.disableCheck) { + disableCreateEnvironmentTrigger(); + } else if (selection === CreateEnv.Trigger.disableCheckWorkspace) { + disableWorkspaceCreateEnvironmentTrigger(); + } + } + } + }), + ); +} diff --git a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts index 7f854690f467..6b6ce182e887 100644 --- a/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts +++ b/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts @@ -9,7 +9,7 @@ import { execObservable } from '../../../common/process/rawProcessApis'; import { createDeferred } from '../../../common/utils/async'; import { Common, CreateEnv } from '../../../common/utils/localize'; import { traceError, traceInfo, traceLog, traceVerbose } from '../../../logging'; -import { CreateEnvironmentProgress } from '../types'; +import { CreateEnvironmentOptionsInternal, CreateEnvironmentProgress } from '../types'; import { pickWorkspaceFolder } from '../common/workspaceSelection'; import { IInterpreterQuickPick } from '../../../interpreter/configuration/types'; import { EnvironmentType, PythonEnvironment } from '../../info'; @@ -152,13 +152,18 @@ async function createVenv( export class VenvCreationProvider implements CreateEnvironmentProvider { constructor(private readonly interpreterQuickPick: IInterpreterQuickPick) {} - public async createEnvironment(options?: CreateEnvironmentOptions): Promise { + public async createEnvironment( + options?: CreateEnvironmentOptions & CreateEnvironmentOptionsInternal, + ): Promise { let workspace: WorkspaceFolder | undefined; const workspaceStep = new MultiStepNode( undefined, async (context?: MultiStepAction) => { try { - workspace = (await pickWorkspaceFolder(undefined, context)) as WorkspaceFolder | undefined; + workspace = (await pickWorkspaceFolder( + { preSelectedWorkspace: options?.workspaceFolder }, + context, + )) as WorkspaceFolder | undefined; } catch (ex) { if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { return ex; diff --git a/src/client/pythonEnvironments/creation/registrations.ts b/src/client/pythonEnvironments/creation/registrations.ts index 7a1102cf7799..8611520fc0f2 100644 --- a/src/client/pythonEnvironments/creation/registrations.ts +++ b/src/client/pythonEnvironments/creation/registrations.ts @@ -6,6 +6,7 @@ import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; import { IInterpreterService } from '../../interpreter/contracts'; import { registerCreateEnvironmentFeatures } from './createEnvApi'; import { registerCreateEnvironmentButtonFeatures } from './createEnvButtonContext'; +import { registerTriggerForPipInTerminal } from './globalPipInTerminalTrigger'; import { registerInstalledPackagesDiagnosticsProvider } from './installedPackagesDiagnostic'; import { registerPyProjectTomlFeatures } from './pyProjectTomlContext'; @@ -20,4 +21,5 @@ export function registerAllCreateEnvironmentFeatures( registerCreateEnvironmentButtonFeatures(disposables); registerPyProjectTomlFeatures(disposables); registerInstalledPackagesDiagnosticsProvider(disposables, interpreterService); + registerTriggerForPipInTerminal(disposables); } diff --git a/src/client/pythonEnvironments/creation/types.ts b/src/client/pythonEnvironments/creation/types.ts index 611af5bf7841..e317bfa6cd11 100644 --- a/src/client/pythonEnvironments/creation/types.ts +++ b/src/client/pythonEnvironments/creation/types.ts @@ -1,6 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License -import { Progress } from 'vscode'; +import { Progress, WorkspaceFolder } from 'vscode'; export interface CreateEnvironmentProgress extends Progress<{ message?: string; increment?: number }> {} + +export interface CreateEnvironmentOptionsInternal { + workspaceFolder?: WorkspaceFolder; + providerId?: string; +} diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index b405143c0ba3..d6225a9eb573 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -112,6 +112,7 @@ export enum EventName { ENVIRONMENT_CHECK_TRIGGER = 'ENVIRONMENT.CHECK.TRIGGER', ENVIRONMENT_CHECK_RESULT = 'ENVIRONMENT.CHECK.RESULT', + ENVIRONMENT_TERMINAL_GLOBAL_PIP = 'ENVIRONMENT.TERMINAL.GLOBAL_PIP', } export enum PlatformErrors { diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 3237bafc224b..070e193e59eb 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -2030,6 +2030,13 @@ export interface IEventNamePropertyMapping { [EventName.ENVIRONMENT_CHECK_RESULT]: { result: 'criteria-met' | 'criteria-not-met' | 'already-ran' | 'turned-off' | 'no-uri'; }; + /** + * Telemetry event sent when `pip install` was called from a global env in a shell where shell inegration is supported. + */ + /* __GDPR__ + "environment.terminal.global_pip" : { "owner": "karthiknadig" } + */ + [EventName.ENVIRONMENT_TERMINAL_GLOBAL_PIP]: never | undefined; /* __GDPR__ "query-expfeature" : { "owner": "luabud", diff --git a/src/test/pythonEnvironments/creation/globalPipInTerminalTrigger.unit.test.ts b/src/test/pythonEnvironments/creation/globalPipInTerminalTrigger.unit.test.ts new file mode 100644 index 000000000000..a9f1b01f3ced --- /dev/null +++ b/src/test/pythonEnvironments/creation/globalPipInTerminalTrigger.unit.test.ts @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import * as sinon from 'sinon'; +import { assert } from 'chai'; +import * as typemoq from 'typemoq'; +import { + Disposable, + Terminal, + TerminalShellExecution, + TerminalShellExecutionStartEvent, + TerminalShellIntegration, + Uri, +} from 'vscode'; +import * as triggerUtils from '../../../client/pythonEnvironments/creation/common/createEnvTriggerUtils'; +import * as windowApis from '../../../client/common/vscodeApis/windowApis'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import * as commandApis from '../../../client/common/vscodeApis/commandApis'; +import * as extDepApi from '../../../client/pythonEnvironments/common/externalDependencies'; +import { registerTriggerForPipInTerminal } from '../../../client/pythonEnvironments/creation/globalPipInTerminalTrigger'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../constants'; +import { CreateEnv } from '../../../client/common/utils/localize'; + +suite('Global Pip in Terminal Trigger', () => { + let shouldPromptToCreateEnvStub: sinon.SinonStub; + let inExperimentStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; + let isGlobalPythonSelectedStub: sinon.SinonStub; + let showWarningMessageStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + let disableCreateEnvironmentTriggerStub: sinon.SinonStub; + let disableWorkspaceCreateEnvironmentTriggerStub: sinon.SinonStub; + let onDidStartTerminalShellExecutionStub: sinon.SinonStub; + let handler: undefined | ((e: TerminalShellExecutionStartEvent) => Promise); + let execEvent: typemoq.IMock; + let shellIntegration: typemoq.IMock; + + const workspace1 = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }; + + const outsideWorkspace = Uri.file( + path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'outsideWorkspace'), + ); + + setup(() => { + shouldPromptToCreateEnvStub = sinon.stub(triggerUtils, 'shouldPromptToCreateEnv'); + inExperimentStub = sinon.stub(extDepApi, 'inExperiment'); + + getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + getWorkspaceFoldersStub.returns([workspace1]); + + getWorkspaceFolderStub = sinon.stub(workspaceApis, 'getWorkspaceFolder'); + getWorkspaceFolderStub.returns(workspace1); + + isGlobalPythonSelectedStub = sinon.stub(triggerUtils, 'isGlobalPythonSelected'); + showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); + + executeCommandStub = sinon.stub(commandApis, 'executeCommand'); + executeCommandStub.resolves({ path: 'some/python' }); + + disableCreateEnvironmentTriggerStub = sinon.stub(triggerUtils, 'disableCreateEnvironmentTrigger'); + disableWorkspaceCreateEnvironmentTriggerStub = sinon.stub( + triggerUtils, + 'disableWorkspaceCreateEnvironmentTrigger', + ); + + onDidStartTerminalShellExecutionStub = sinon.stub(windowApis, 'onDidStartTerminalShellExecution'); + onDidStartTerminalShellExecutionStub.callsFake((cb) => { + handler = cb; + return { + dispose: () => { + handler = undefined; + }, + }; + }); + + shellIntegration = typemoq.Mock.ofType(); + execEvent = typemoq.Mock.ofType(); + execEvent.setup((e) => e.shellIntegration).returns(() => shellIntegration.object); + shellIntegration + .setup((s) => s.executeCommand(typemoq.It.isAnyString())) + .returns(() => (({} as unknown) as TerminalShellExecution)); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Should not prompt to create environment if setting is off', async () => { + shouldPromptToCreateEnvStub.returns(false); + inExperimentStub.returns(true); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + assert.strictEqual(disposables.length, 0); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + }); + + test('Should not prompt to create environment if experiment is off', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(false); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + assert.strictEqual(disposables.length, 0); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + }); + + test('Should not prompt to create environment if no workspace folders', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + getWorkspaceFoldersStub.returns([]); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + assert.strictEqual(disposables.length, 0); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFoldersStub); + }); + + test('Should not prompt to create environment if workspace folder is not found', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + getWorkspaceFolderStub.returns(undefined); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + shellIntegration.setup((s) => s.cwd).returns(() => outsideWorkspace); + await handler?.(({ shellIntegration: shellIntegration.object } as unknown) as TerminalShellExecutionStartEvent); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + + sinon.assert.notCalled(isGlobalPythonSelectedStub); + sinon.assert.notCalled(showWarningMessageStub); + }); + + test('Should not prompt to create environment if global python is not selected', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + isGlobalPythonSelectedStub.returns(false); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + await handler?.(({ shellIntegration: shellIntegration.object } as unknown) as TerminalShellExecutionStartEvent); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + sinon.assert.calledOnce(isGlobalPythonSelectedStub); + + sinon.assert.notCalled(showWarningMessageStub); + }); + + test('Should not prompt to create environment if command is not trusted', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + isGlobalPythonSelectedStub.returns(true); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + await handler?.({ + terminal: ({} as unknown) as Terminal, + shellIntegration: shellIntegration.object, + execution: { + cwd: workspace1.uri, + commandLine: { + isTrusted: false, + value: 'pip install', + confidence: 0, + }, + read: () => + (async function* () { + yield Promise.resolve('pip install'); + })(), + }, + }); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + sinon.assert.calledOnce(isGlobalPythonSelectedStub); + + sinon.assert.notCalled(showWarningMessageStub); + }); + + test('Should not prompt to create environment if command does not start with pip install', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + isGlobalPythonSelectedStub.returns(true); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + await handler?.({ + terminal: ({} as unknown) as Terminal, + shellIntegration: shellIntegration.object, + execution: { + cwd: workspace1.uri, + commandLine: { + isTrusted: false, + value: 'some command pip install', + confidence: 0, + }, + read: () => + (async function* () { + yield Promise.resolve('pip install'); + })(), + }, + }); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + sinon.assert.calledOnce(isGlobalPythonSelectedStub); + + sinon.assert.notCalled(showWarningMessageStub); + }); + + test('Should prompt to create environment if all conditions are met', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + isGlobalPythonSelectedStub.returns(true); + showWarningMessageStub.resolves(CreateEnv.Trigger.createEnvironment); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + await handler?.({ + terminal: ({} as unknown) as Terminal, + shellIntegration: shellIntegration.object, + execution: { + cwd: workspace1.uri, + commandLine: { + isTrusted: true, + value: 'pip install', + confidence: 0, + }, + read: () => + (async function* () { + yield Promise.resolve('pip install'); + })(), + }, + }); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + sinon.assert.calledOnce(isGlobalPythonSelectedStub); + sinon.assert.calledOnce(showWarningMessageStub); + + sinon.assert.calledOnce(executeCommandStub); + sinon.assert.notCalled(disableCreateEnvironmentTriggerStub); + sinon.assert.notCalled(disableWorkspaceCreateEnvironmentTriggerStub); + + shellIntegration.verify((s) => s.executeCommand(typemoq.It.isAnyString()), typemoq.Times.once()); + }); + + test('Should disable create environment trigger if user selects to disable', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + isGlobalPythonSelectedStub.returns(true); + showWarningMessageStub.resolves(CreateEnv.Trigger.disableCheck); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + await handler?.({ + terminal: ({} as unknown) as Terminal, + shellIntegration: shellIntegration.object, + execution: { + cwd: workspace1.uri, + commandLine: { + isTrusted: true, + value: 'pip install', + confidence: 0, + }, + read: () => + (async function* () { + yield Promise.resolve('pip install'); + })(), + }, + }); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + sinon.assert.calledOnce(isGlobalPythonSelectedStub); + sinon.assert.calledOnce(showWarningMessageStub); + + sinon.assert.notCalled(executeCommandStub); + sinon.assert.calledOnce(disableCreateEnvironmentTriggerStub); + sinon.assert.notCalled(disableWorkspaceCreateEnvironmentTriggerStub); + }); + + test('Should disable workspace create environment trigger if user selects to disable', async () => { + shouldPromptToCreateEnvStub.returns(true); + inExperimentStub.returns(true); + isGlobalPythonSelectedStub.returns(true); + showWarningMessageStub.resolves(CreateEnv.Trigger.disableCheckWorkspace); + + const disposables: Disposable[] = []; + registerTriggerForPipInTerminal(disposables); + + await handler?.({ + terminal: ({} as unknown) as Terminal, + shellIntegration: shellIntegration.object, + execution: { + cwd: workspace1.uri, + commandLine: { + isTrusted: true, + value: 'pip install', + confidence: 0, + }, + read: () => + (async function* () { + yield Promise.resolve('pip install'); + })(), + }, + }); + + assert.strictEqual(disposables.length, 1); + sinon.assert.calledOnce(shouldPromptToCreateEnvStub); + sinon.assert.calledOnce(inExperimentStub); + sinon.assert.calledOnce(getWorkspaceFolderStub); + sinon.assert.calledOnce(isGlobalPythonSelectedStub); + sinon.assert.calledOnce(showWarningMessageStub); + + sinon.assert.notCalled(executeCommandStub); + sinon.assert.notCalled(disableCreateEnvironmentTriggerStub); + sinon.assert.calledOnce(disableWorkspaceCreateEnvironmentTriggerStub); + }); +}); diff --git a/types/vscode.proposed.terminalShellIntegration.d.ts b/types/vscode.proposed.terminalShellIntegration.d.ts new file mode 100644 index 000000000000..5ff18b60ca72 --- /dev/null +++ b/types/vscode.proposed.terminalShellIntegration.d.ts @@ -0,0 +1,312 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/145234 + + /** + * A command that was executed in a terminal. + */ + export interface TerminalShellExecution { + /** + * The command line that was executed. The {@link TerminalShellExecutionCommandLineConfidence confidence} + * of this value depends on the specific shell's shell integration implementation. This + * value may become more accurate after {@link window.onDidEndTerminalShellExecution} is + * fired. + * + * @example + * // Log the details of the command line on start and end + * window.onDidStartTerminalShellExecution(event => { + * const commandLine = event.execution.commandLine; + * console.log(`Command started\n${summarizeCommandLine(commandLine)}`); + * }); + * window.onDidEndTerminalShellExecution(event => { + * const commandLine = event.execution.commandLine; + * console.log(`Command ended\n${summarizeCommandLine(commandLine)}`); + * }); + * function summarizeCommandLine(commandLine: TerminalShellExecutionCommandLine) { + * return [ + * ` Command line: ${command.ommandLine.value}`, + * ` Confidence: ${command.ommandLine.confidence}`, + * ` Trusted: ${command.ommandLine.isTrusted} + * ].join('\n'); + * } + */ + readonly commandLine: TerminalShellExecutionCommandLine; + + /** + * The working directory that was reported by the shell when this command executed. This + * {@link Uri} may represent a file on another machine (eg. ssh into another machine). This + * requires the shell integration to support working directory reporting. + */ + readonly cwd: Uri | undefined; + + /** + * Creates a stream of raw data (including escape sequences) that is written to the + * terminal. This will only include data that was written after `readData` was called for + * the first time, ie. you must call `readData` immediately after the command is executed + * via {@link TerminalShellIntegration.executeCommand} or + * {@link window.onDidStartTerminalShellExecution} to not miss any data. + * + * @example + * // Log all data written to the terminal for a command + * const command = term.shellIntegration.executeCommand({ commandLine: 'echo "Hello world"' }); + * const stream = command.read(); + * for await (const data of stream) { + * console.log(data); + * } + */ + read(): AsyncIterable; + } + + /** + * A command line that was executed in a terminal. + */ + export interface TerminalShellExecutionCommandLine { + /** + * The full command line that was executed, including both the command and its arguments. + */ + readonly value: string; + + /** + * Whether the command line value came from a trusted source and is therefore safe to + * execute without user additional confirmation, such as a notification that asks "Do you + * want to execute (command)?". This verification is likely only needed if you are going to + * execute the command again. + * + * This is `true` only when the command line was reported explicitly by the shell + * integration script (ie. {@link TerminalShellExecutionCommandLineConfidence.High high confidence}) + * and it used a nonce for verification. + */ + readonly isTrusted: boolean; + + /** + * The confidence of the command line value which is determined by how the value was + * obtained. This depends upon the implementation of the shell integration script. + */ + readonly confidence: TerminalShellExecutionCommandLineConfidence; + } + + /** + * The confidence of a {@link TerminalShellExecutionCommandLine} value. + */ + enum TerminalShellExecutionCommandLineConfidence { + /** + * The command line value confidence is low. This means that the value was read from the + * terminal buffer using markers reported by the shell integration script. Additionally one + * of the following conditions will be met: + * + * - The command started on the very left-most column which is unusual, or + * - The command is multi-line which is more difficult to accurately detect due to line + * continuation characters and right prompts. + * - Command line markers were not reported by the shell integration script. + */ + Low = 0, + + /** + * The command line value confidence is medium. This means that the value was read from the + * terminal buffer using markers reported by the shell integration script. The command is + * single-line and does not start on the very left-most column (which is unusual). + */ + Medium = 1, + + /** + * The command line value confidence is high. This means that the value was explicitly sent + * from the shell integration script or the command was executed via the + * {@link TerminalShellIntegration.executeCommand} API. + */ + High = 2, + } + + export interface Terminal { + /** + * An object that contains [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration)-powered + * features for the terminal. This will always be `undefined` immediately after the terminal + * is created. Listen to {@link window.onDidActivateTerminalShellIntegration} to be notified + * when shell integration is activated for a terminal. + * + * Note that this object may remain undefined if shell integation never activates. For + * example Command Prompt does not support shell integration and a user's shell setup could + * conflict with the automatic shell integration activation. + */ + readonly shellIntegration: TerminalShellIntegration | undefined; + } + + /** + * [Shell integration](https://code.visualstudio.com/docs/terminal/shell-integration)-powered capabilities owned by a terminal. + */ + export interface TerminalShellIntegration { + /** + * The current working directory of the terminal. This {@link Uri} may represent a file on + * another machine (eg. ssh into another machine). This requires the shell integration to + * support working directory reporting. + */ + readonly cwd: Uri | undefined; + + /** + * Execute a command, sending ^C as necessary to interrupt any running command if needed. + * + * @param commandLine The command line to execute, this is the exact text that will be sent + * to the terminal. + * + * @example + * // Execute a command in a terminal immediately after being created + * const myTerm = window.createTerminal(); + * window.onDidActivateTerminalShellIntegration(async ({ terminal, shellIntegration }) => { + * if (terminal === myTerm) { + * const command = shellIntegration.executeCommand('echo "Hello world"'); + * const code = await command.exitCode; + * console.log(`Command exited with code ${code}`); + * } + * })); + * // Fallback to sendText if there is no shell integration within 3 seconds of launching + * setTimeout(() => { + * if (!myTerm.shellIntegration) { + * myTerm.sendText('echo "Hello world"'); + * // Without shell integration, we can't know when the command has finished or what the + * // exit code was. + * } + * }, 3000); + * + * @example + * // Send command to terminal that has been alive for a while + * const commandLine = 'echo "Hello world"'; + * if (term.shellIntegration) { + * const command = term.shellIntegration.executeCommand({ commandLine }); + * const code = await command.exitCode; + * console.log(`Command exited with code ${code}`); + * } else { + * term.sendText(commandLine); + * // Without shell integration, we can't know when the command has finished or what the + * // exit code was. + * } + */ + executeCommand(commandLine: string): TerminalShellExecution; + + /** + * Execute a command, sending ^C as necessary to interrupt any running command if needed. + * + * *Note* This is not guaranteed to work as [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) + * must be activated. Check whether {@link TerminalShellExecution.exitCode} is rejected to + * verify whether it was successful. + * + * @param command A command to run. + * @param args Arguments to launch the executable with which will be automatically escaped + * based on the executable type. + * + * @example + * // Execute a command in a terminal immediately after being created + * const myTerm = window.createTerminal(); + * window.onDidActivateTerminalShellIntegration(async ({ terminal, shellIntegration }) => { + * if (terminal === myTerm) { + * const command = shellIntegration.executeCommand({ + * command: 'echo', + * args: ['Hello world'] + * }); + * const code = await command.exitCode; + * console.log(`Command exited with code ${code}`); + * } + * })); + * // Fallback to sendText if there is no shell integration within 3 seconds of launching + * setTimeout(() => { + * if (!myTerm.shellIntegration) { + * myTerm.sendText('echo "Hello world"'); + * // Without shell integration, we can't know when the command has finished or what the + * // exit code was. + * } + * }, 3000); + * + * @example + * // Send command to terminal that has been alive for a while + * const commandLine = 'echo "Hello world"'; + * if (term.shellIntegration) { + * const command = term.shellIntegration.executeCommand({ + * command: 'echo', + * args: ['Hello world'] + * }); + * const code = await command.exitCode; + * console.log(`Command exited with code ${code}`); + * } else { + * term.sendText(commandLine); + * // Without shell integration, we can't know when the command has finished or what the + * // exit code was. + * } + */ + executeCommand(executable: string, args: string[]): TerminalShellExecution; + } + + export interface TerminalShellIntegrationChangeEvent { + /** + * The terminal that shell integration has been activated in. + */ + readonly terminal: Terminal; + + /** + * The shell integration object. + */ + readonly shellIntegration: TerminalShellIntegration; + } + + export interface TerminalShellExecutionStartEvent { + /** + * The terminal that shell integration has been activated in. + */ + readonly terminal: Terminal; + + /** + * The shell integration object. + */ + readonly shellIntegration: TerminalShellIntegration; + + /** + * The terminal shell execution that has ended. + */ + readonly execution: TerminalShellExecution; + } + + export interface TerminalShellExecutionEndEvent { + /** + * The terminal that shell integration has been activated in. + */ + readonly terminal: Terminal; + + /** + * The shell integration object. + */ + readonly shellIntegration: TerminalShellIntegration; + + /** + * The terminal shell execution that has ended. + */ + readonly execution: TerminalShellExecution; + + /** + * The exit code reported by the shell. `undefined` means the shell did not report an exit + * code or the shell reported a command started before the command finished. + */ + readonly exitCode: number | undefined; + } + + export namespace window { + /** + * Fires when shell integration activates or one of its properties changes in a terminal. + */ + export const onDidChangeTerminalShellIntegration: Event; + + /** + * This will be fired when a terminal command is started. This event will fire only when + * [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) is + * activated for the terminal. + */ + export const onDidStartTerminalShellExecution: Event; + + /** + * This will be fired when a terminal command is ended. This event will fire only when + * [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) is + * activated for the terminal. + */ + export const onDidEndTerminalShellExecution: Event; + } +} From aff4f8278429d3df8da1f192e67041ee8aed5872 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 16 Apr 2024 14:11:32 -0700 Subject: [PATCH 2/2] Set vscode engine to have the proposed API --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 96e7b3e204e8..6d0180c6a2dc 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.86.0" + "vscode": "^1.89.0-20240415" }, "enableTelemetry": false, "keywords": [