From 66cea2169ef4c46a96e42725c4793ccce3b5c5df Mon Sep 17 00:00:00 2001 From: Kartik Raj Date: Wed, 4 Oct 2023 16:29:51 -0700 Subject: [PATCH] Show notification when deactivate command is run in terminal (#22133) Closes https://github.com/microsoft/vscode-python/issues/22121 --- package.json | 3 +- .../common/application/applicationShell.ts | 12 +- src/client/common/application/types.ts | 18 ++ src/client/common/utils/localize.ts | 4 + src/client/interpreter/activation/types.ts | 8 - src/client/interpreter/serviceRegistry.ts | 13 +- src/client/telemetry/constants.ts | 1 + src/client/telemetry/index.ts | 18 ++ .../deactivatePrompt.ts | 91 +++++++ .../indicatorPrompt.ts} | 6 +- .../envCollectionActivation/service.ts} | 7 +- src/client/terminals/serviceRegistry.ts | 38 ++- src/client/terminals/types.ts | 8 + ...erminalEnvVarCollectionPrompt.unit.test.ts | 8 +- ...rminalEnvVarCollectionService.unit.test.ts | 2 +- .../deactivatePrompt.unit.test.ts | 251 ++++++++++++++++++ .../terminals/serviceRegistry.unit.test.ts | 16 ++ ...scode.proposed.terminalDataWriteEvent.d.ts | 31 +++ 18 files changed, 490 insertions(+), 45 deletions(-) create mode 100644 src/client/terminals/envCollectionActivation/deactivatePrompt.ts rename src/client/{interpreter/activation/terminalEnvVarCollectionPrompt.ts => terminals/envCollectionActivation/indicatorPrompt.ts} (95%) rename src/client/{interpreter/activation/terminalEnvVarCollectionService.ts => terminals/envCollectionActivation/service.ts} (98%) create mode 100644 src/test/terminals/envCollectionActivation/deactivatePrompt.unit.test.ts create mode 100644 typings/vscode-proposed/vscode.proposed.terminalDataWriteEvent.d.ts diff --git a/package.json b/package.json index 47faec663015..df2d2546e4d8 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "quickPickSortByLabel", "testObserver", "quickPickItemTooltip", - "saveEditor" + "saveEditor", + "terminalDataWriteEvent" ], "author": { "name": "Microsoft Corporation" diff --git a/src/client/common/application/applicationShell.ts b/src/client/common/application/applicationShell.ts index 454662472010..aadf80186900 100644 --- a/src/client/common/application/applicationShell.ts +++ b/src/client/common/application/applicationShell.ts @@ -10,6 +10,7 @@ import { DocumentSelector, env, Event, + EventEmitter, InputBox, InputBoxOptions, languages, @@ -37,7 +38,8 @@ import { WorkspaceFolder, WorkspaceFolderPickOptions, } from 'vscode'; -import { IApplicationShell } from './types'; +import { traceError } from '../../logging'; +import { IApplicationShell, TerminalDataWriteEvent } from './types'; @injectable() export class ApplicationShell implements IApplicationShell { @@ -172,4 +174,12 @@ export class ApplicationShell implements IApplicationShell { public createLanguageStatusItem(id: string, selector: DocumentSelector): LanguageStatusItem { return languages.createLanguageStatusItem(id, selector); } + public get onDidWriteTerminalData(): Event { + try { + return window.onDidWriteTerminalData; + } catch (ex) { + traceError('Failed to get proposed API onDidWriteTerminalData', ex); + return new EventEmitter().event; + } + } } diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index fa2ced6c45da..863f5e4651b2 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -67,6 +67,17 @@ import { Resource } from '../types'; import { ICommandNameArgumentTypeMapping } from './commands'; import { ExtensionContextKey } from './contextKeys'; +export interface TerminalDataWriteEvent { + /** + * The {@link Terminal} for which the data was written. + */ + readonly terminal: Terminal; + /** + * The data being written. + */ + readonly data: string; +} + export const IApplicationShell = Symbol('IApplicationShell'); export interface IApplicationShell { /** @@ -75,6 +86,13 @@ export interface IApplicationShell { */ readonly onDidChangeWindowState: Event; + /** + * An event which fires when the terminal's child pseudo-device is written to (the shell). + * In other words, this provides access to the raw data stream from the process running + * within the terminal, including VT sequences. + */ + readonly onDidWriteTerminalData: Event; + showInformationMessage(message: string, ...items: string[]): Thenable; /** diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index bc32c1078cad..fc118699d2c7 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -201,6 +201,10 @@ export namespace Interpreters { export const terminalEnvVarCollectionPrompt = l10n.t( 'The Python extension automatically activates all terminals using the selected environment, even when the name of the environment{0} is not present in the terminal prompt. [Learn more](https://aka.ms/vscodePythonTerminalActivation).', ); + export const terminalDeactivatePrompt = l10n.t( + 'Deactivating virtual environments may not work by default due to a technical limitation in our activation approach, but it can be resolved with a few simple steps.', + ); + export const deactivateDoneButton = l10n.t('Done, it works'); export const activatedCondaEnvLaunch = l10n.t( 'We noticed VS Code was launched from an activated conda environment, would you like to select it?', ); diff --git a/src/client/interpreter/activation/types.ts b/src/client/interpreter/activation/types.ts index 2b364cbeb862..e00ef9b62b3f 100644 --- a/src/client/interpreter/activation/types.ts +++ b/src/client/interpreter/activation/types.ts @@ -21,11 +21,3 @@ export interface IEnvironmentActivationService { interpreter?: PythonEnvironment, ): Promise; } - -export const ITerminalEnvVarCollectionService = Symbol('ITerminalEnvVarCollectionService'); -export interface ITerminalEnvVarCollectionService { - /** - * Returns true if we know with high certainity the terminal prompt is set correctly for a particular resource. - */ - isTerminalPromptSetCorrectly(resource?: Resource): boolean; -} diff --git a/src/client/interpreter/serviceRegistry.ts b/src/client/interpreter/serviceRegistry.ts index 018e7abfdc46..422776bd5e43 100644 --- a/src/client/interpreter/serviceRegistry.ts +++ b/src/client/interpreter/serviceRegistry.ts @@ -6,9 +6,7 @@ import { IExtensionActivationService, IExtensionSingleActivationService } from '../activation/types'; import { IServiceManager } from '../ioc/types'; import { EnvironmentActivationService } from './activation/service'; -import { TerminalEnvVarCollectionPrompt } from './activation/terminalEnvVarCollectionPrompt'; -import { TerminalEnvVarCollectionService } from './activation/terminalEnvVarCollectionService'; -import { IEnvironmentActivationService, ITerminalEnvVarCollectionService } from './activation/types'; +import { IEnvironmentActivationService } from './activation/types'; import { InterpreterAutoSelectionService } from './autoSelection/index'; import { InterpreterAutoSelectionProxyService } from './autoSelection/proxy'; import { IInterpreterAutoSelectionService, IInterpreterAutoSelectionProxyService } from './autoSelection/types'; @@ -110,13 +108,4 @@ export function registerTypes(serviceManager: IServiceManager): void { IEnvironmentActivationService, EnvironmentActivationService, ); - serviceManager.addSingleton( - ITerminalEnvVarCollectionService, - TerminalEnvVarCollectionService, - ); - serviceManager.addBinding(ITerminalEnvVarCollectionService, IExtensionActivationService); - serviceManager.addSingleton( - IExtensionSingleActivationService, - TerminalEnvVarCollectionPrompt, - ); } diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index c680b91094cb..4b4dc302dc3f 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -29,6 +29,7 @@ export enum EventName { TERMINAL_SHELL_IDENTIFICATION = 'TERMINAL_SHELL_IDENTIFICATION', PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT = 'PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT', PYTHON_NOT_INSTALLED_PROMPT = 'PYTHON_NOT_INSTALLED_PROMPT', + TERMINAL_DEACTIVATE_PROMPT = 'TERMINAL_DEACTIVATE_PROMPT', CONDA_INHERIT_ENV_PROMPT = 'CONDA_INHERIT_ENV_PROMPT', REQUIRE_JUPYTER_PROMPT = 'REQUIRE_JUPYTER_PROMPT', ACTIVATED_CONDA_ENV_LAUNCH = 'ACTIVATED_CONDA_ENV_LAUNCH', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 600f9a2d48ff..bd60b9281a93 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1328,6 +1328,24 @@ export interface IEventNamePropertyMapping { */ selection: 'Allow' | 'Close' | undefined; }; + /** + * Telemetry event sent with details when user clicks the prompt with the following message: + * + * 'Deactivating virtual environments may not work by default due to a technical limitation in our activation approach, but it can be resolved with a few simple steps.' + */ + /* __GDPR__ + "terminal_deactivate_prompt" : { + "selection" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karrtikr" } + } + */ + [EventName.TERMINAL_DEACTIVATE_PROMPT]: { + /** + * `See Instructions` When 'See Instructions' option is selected + * `Done, it works` When 'Done, it works' option is selected + * `Don't show again` When 'Don't show again' option is selected + */ + selection: 'See Instructions' | 'Done, it works' | "Don't show again" | undefined; + }; /** * Telemetry event sent with details when user attempts to run in interactive window when Jupyter is not installed. */ diff --git a/src/client/terminals/envCollectionActivation/deactivatePrompt.ts b/src/client/terminals/envCollectionActivation/deactivatePrompt.ts new file mode 100644 index 000000000000..460144303f18 --- /dev/null +++ b/src/client/terminals/envCollectionActivation/deactivatePrompt.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import { Uri } from 'vscode'; +import { IApplicationEnvironment, IApplicationShell } from '../../common/application/types'; +import { IBrowserService, IDisposableRegistry, IExperimentService, IPersistentStateFactory } from '../../common/types'; +import { Common, Interpreters } from '../../common/utils/localize'; +import { IExtensionSingleActivationService } from '../../activation/types'; +import { inTerminalEnvVarExperiment } from '../../common/experiments/helpers'; +import { IInterpreterService } from '../../interpreter/contracts'; +import { PythonEnvType } from '../../pythonEnvironments/base/info'; +import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector'; +import { TerminalShellType } from '../../common/terminal/types'; +import { sendTelemetryEvent } from '../../telemetry'; +import { EventName } from '../../telemetry/constants'; + +export const terminalDeactivationPromptKey = 'TERMINAL_DEACTIVATION_PROMPT_KEY'; + +@injectable() +export class TerminalDeactivateLimitationPrompt implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + + constructor( + @inject(IApplicationShell) private readonly appShell: IApplicationShell, + @inject(IPersistentStateFactory) private readonly persistentStateFactory: IPersistentStateFactory, + @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry, + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, + @inject(IBrowserService) private readonly browserService: IBrowserService, + @inject(IApplicationEnvironment) private readonly appEnvironment: IApplicationEnvironment, + @inject(IExperimentService) private readonly experimentService: IExperimentService, + ) {} + + public async activate(): Promise { + if (!inTerminalEnvVarExperiment(this.experimentService)) { + return; + } + this.disposableRegistry.push( + this.appShell.onDidWriteTerminalData(async (e) => { + if (!e.data.includes('deactivate')) { + return; + } + const shellType = identifyShellFromShellPath(this.appEnvironment.shell); + if (shellType === TerminalShellType.commandPrompt) { + return; + } + const { terminal } = e; + const cwd = + 'cwd' in terminal.creationOptions && terminal.creationOptions.cwd + ? terminal.creationOptions.cwd + : undefined; + const resource = typeof cwd === 'string' ? Uri.file(cwd) : cwd; + const interpreter = await this.interpreterService.getActiveInterpreter(resource); + if (interpreter?.type !== PythonEnvType.Virtual) { + return; + } + await this.notifyUsers(); + }), + ); + } + + private async notifyUsers(): Promise { + const notificationPromptEnabled = this.persistentStateFactory.createGlobalPersistentState( + terminalDeactivationPromptKey, + true, + ); + if (!notificationPromptEnabled.value) { + return; + } + const prompts = [Common.seeInstructions, Interpreters.deactivateDoneButton, Common.doNotShowAgain]; + const telemetrySelections: ['See Instructions', 'Done, it works', "Don't show again"] = [ + 'See Instructions', + 'Done, it works', + "Don't show again", + ]; + const selection = await this.appShell.showWarningMessage(Interpreters.terminalDeactivatePrompt, ...prompts); + if (!selection) { + return; + } + sendTelemetryEvent(EventName.TERMINAL_DEACTIVATE_PROMPT, undefined, { + selection: selection ? telemetrySelections[prompts.indexOf(selection)] : undefined, + }); + if (selection === prompts[0]) { + const url = `https://aka.ms/AAmx2ft`; + this.browserService.launch(url); + } + if (selection === prompts[1] || selection === prompts[2]) { + await notificationPromptEnabled.updateValue(false); + } + } +} diff --git a/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts b/src/client/terminals/envCollectionActivation/indicatorPrompt.ts similarity index 95% rename from src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts rename to src/client/terminals/envCollectionActivation/indicatorPrompt.ts index c8aea205a32a..bf648eefe8e9 100644 --- a/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts +++ b/src/client/terminals/envCollectionActivation/indicatorPrompt.ts @@ -14,15 +14,15 @@ import { } from '../../common/types'; import { Common, Interpreters } from '../../common/utils/localize'; import { IExtensionSingleActivationService } from '../../activation/types'; -import { ITerminalEnvVarCollectionService } from './types'; import { inTerminalEnvVarExperiment } from '../../common/experiments/helpers'; -import { IInterpreterService } from '../contracts'; +import { IInterpreterService } from '../../interpreter/contracts'; import { PythonEnvironment } from '../../pythonEnvironments/info'; +import { ITerminalEnvVarCollectionService } from '../types'; export const terminalEnvCollectionPromptKey = 'TERMINAL_ENV_COLLECTION_PROMPT_KEY'; @injectable() -export class TerminalEnvVarCollectionPrompt implements IExtensionSingleActivationService { +export class TerminalIndicatorPrompt implements IExtensionSingleActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; constructor( diff --git a/src/client/interpreter/activation/terminalEnvVarCollectionService.ts b/src/client/terminals/envCollectionActivation/service.ts similarity index 98% rename from src/client/interpreter/activation/terminalEnvVarCollectionService.ts rename to src/client/terminals/envCollectionActivation/service.ts index c11ec221d4d7..ae346d264eeb 100644 --- a/src/client/interpreter/activation/terminalEnvVarCollectionService.ts +++ b/src/client/terminals/envCollectionActivation/service.ts @@ -28,9 +28,9 @@ import { import { Deferred, createDeferred } from '../../common/utils/async'; import { Interpreters } from '../../common/utils/localize'; import { traceDecoratorVerbose, traceError, traceVerbose, traceWarn } from '../../logging'; -import { IInterpreterService } from '../contracts'; -import { defaultShells } from './service'; -import { IEnvironmentActivationService, ITerminalEnvVarCollectionService } from './types'; +import { IInterpreterService } from '../../interpreter/contracts'; +import { defaultShells } from '../../interpreter/activation/service'; +import { IEnvironmentActivationService } from '../../interpreter/activation/types'; import { EnvironmentType, PythonEnvironment } from '../../pythonEnvironments/info'; import { getSearchPathEnvVarNames } from '../../common/utils/exec'; import { EnvironmentVariables } from '../../common/variables/types'; @@ -38,6 +38,7 @@ import { TerminalShellType } from '../../common/terminal/types'; import { OSType } from '../../common/utils/platform'; import { normCase } from '../../common/platform/fs-paths'; import { PythonEnvType } from '../../pythonEnvironments/base/info'; +import { ITerminalEnvVarCollectionService } from '../types'; @injectable() export class TerminalEnvVarCollectionService implements IExtensionActivationService, ITerminalEnvVarCollectionService { diff --git a/src/client/terminals/serviceRegistry.ts b/src/client/terminals/serviceRegistry.ts index a39ef31a8fe4..a9da776d011a 100644 --- a/src/client/terminals/serviceRegistry.ts +++ b/src/client/terminals/serviceRegistry.ts @@ -1,25 +1,26 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { interfaces } from 'inversify'; -import { ClassType } from '../ioc/types'; +import { IServiceManager } from '../ioc/types'; import { TerminalAutoActivation } from './activation'; import { CodeExecutionManager } from './codeExecution/codeExecutionManager'; import { DjangoShellCodeExecutionProvider } from './codeExecution/djangoShellCodeExecution'; import { CodeExecutionHelper } from './codeExecution/helper'; import { ReplProvider } from './codeExecution/repl'; import { TerminalCodeExecutionProvider } from './codeExecution/terminalCodeExecution'; -import { ICodeExecutionHelper, ICodeExecutionManager, ICodeExecutionService, ITerminalAutoActivation } from './types'; +import { + ICodeExecutionHelper, + ICodeExecutionManager, + ICodeExecutionService, + ITerminalAutoActivation, + ITerminalEnvVarCollectionService, +} from './types'; +import { TerminalEnvVarCollectionService } from './envCollectionActivation/service'; +import { IExtensionActivationService, IExtensionSingleActivationService } from '../activation/types'; +import { TerminalDeactivateLimitationPrompt } from './envCollectionActivation/deactivatePrompt'; +import { TerminalIndicatorPrompt } from './envCollectionActivation/indicatorPrompt'; -interface IServiceRegistry { - addSingleton( - serviceIdentifier: interfaces.ServiceIdentifier, - constructor: ClassType, - name?: string | number | symbol, - ): void; -} - -export function registerTypes(serviceManager: IServiceRegistry): void { +export function registerTypes(serviceManager: IServiceManager): void { serviceManager.addSingleton(ICodeExecutionHelper, CodeExecutionHelper); serviceManager.addSingleton(ICodeExecutionManager, CodeExecutionManager); @@ -37,4 +38,17 @@ export function registerTypes(serviceManager: IServiceRegistry): void { serviceManager.addSingleton(ICodeExecutionService, ReplProvider, 'repl'); serviceManager.addSingleton(ITerminalAutoActivation, TerminalAutoActivation); + serviceManager.addSingleton( + ITerminalEnvVarCollectionService, + TerminalEnvVarCollectionService, + ); + serviceManager.addSingleton( + IExtensionSingleActivationService, + TerminalIndicatorPrompt, + ); + serviceManager.addSingleton( + IExtensionSingleActivationService, + TerminalDeactivateLimitationPrompt, + ); + serviceManager.addBinding(ITerminalEnvVarCollectionService, IExtensionActivationService); } diff --git a/src/client/terminals/types.ts b/src/client/terminals/types.ts index 47ac16d9e08b..48d60adf3f39 100644 --- a/src/client/terminals/types.ts +++ b/src/client/terminals/types.ts @@ -33,3 +33,11 @@ export interface ITerminalAutoActivation extends IDisposable { register(): void; disableAutoActivation(terminal: Terminal): void; } + +export const ITerminalEnvVarCollectionService = Symbol('ITerminalEnvVarCollectionService'); +export interface ITerminalEnvVarCollectionService { + /** + * Returns true if we know with high certainity the terminal prompt is set correctly for a particular resource. + */ + isTerminalPromptSetCorrectly(resource?: Resource): boolean; +} diff --git a/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts b/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts index baa83c8b11c5..5d4da49ebb45 100644 --- a/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts +++ b/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts @@ -13,13 +13,13 @@ import { IPersistentStateFactory, IPythonSettings, } from '../../../client/common/types'; -import { TerminalEnvVarCollectionPrompt } from '../../../client/interpreter/activation/terminalEnvVarCollectionPrompt'; -import { ITerminalEnvVarCollectionService } from '../../../client/interpreter/activation/types'; +import { TerminalIndicatorPrompt } from '../../../client/terminals/envCollectionActivation/indicatorPrompt'; import { Common, Interpreters } from '../../../client/common/utils/localize'; import { TerminalEnvVarActivation } from '../../../client/common/experiments/groups'; import { sleep } from '../../core'; import { IInterpreterService } from '../../../client/interpreter/contracts'; import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { ITerminalEnvVarCollectionService } from '../../../client/terminals/types'; suite('Terminal Environment Variable Collection Prompt', () => { let shell: IApplicationShell; @@ -28,7 +28,7 @@ suite('Terminal Environment Variable Collection Prompt', () => { let activeResourceService: IActiveResourceService; let terminalEnvVarCollectionService: ITerminalEnvVarCollectionService; let persistentStateFactory: IPersistentStateFactory; - let terminalEnvVarCollectionPrompt: TerminalEnvVarCollectionPrompt; + let terminalEnvVarCollectionPrompt: TerminalIndicatorPrompt; let terminalEventEmitter: EventEmitter; let notificationEnabled: IPersistentState; let configurationService: IConfigurationService; @@ -61,7 +61,7 @@ suite('Terminal Environment Variable Collection Prompt', () => { ); when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(true); when(terminalManager.onDidOpenTerminal).thenReturn(terminalEventEmitter.event); - terminalEnvVarCollectionPrompt = new TerminalEnvVarCollectionPrompt( + terminalEnvVarCollectionPrompt = new TerminalIndicatorPrompt( instance(shell), instance(persistentStateFactory), instance(terminalManager), diff --git a/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts b/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts index e41d6ce4d53c..5e572e7ad06f 100644 --- a/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts +++ b/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts @@ -32,7 +32,7 @@ import { import { Interpreters } from '../../../client/common/utils/localize'; import { OSType, getOSType } from '../../../client/common/utils/platform'; import { defaultShells } from '../../../client/interpreter/activation/service'; -import { TerminalEnvVarCollectionService } from '../../../client/interpreter/activation/terminalEnvVarCollectionService'; +import { TerminalEnvVarCollectionService } from '../../../client/terminals/envCollectionActivation/service'; import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; import { IInterpreterService } from '../../../client/interpreter/contracts'; import { PathUtils } from '../../../client/common/platform/pathUtils'; diff --git a/src/test/terminals/envCollectionActivation/deactivatePrompt.unit.test.ts b/src/test/terminals/envCollectionActivation/deactivatePrompt.unit.test.ts new file mode 100644 index 000000000000..acd8ee99e5d7 --- /dev/null +++ b/src/test/terminals/envCollectionActivation/deactivatePrompt.unit.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { mock, when, anything, instance, verify, reset } from 'ts-mockito'; +import { EventEmitter, Terminal, TerminalDataWriteEvent, Uri } from 'vscode'; +import { IApplicationEnvironment, IApplicationShell } from '../../../client/common/application/types'; +import { + IBrowserService, + IExperimentService, + IPersistentState, + IPersistentStateFactory, +} from '../../../client/common/types'; +import { Common, Interpreters } from '../../../client/common/utils/localize'; +import { TerminalEnvVarActivation } from '../../../client/common/experiments/groups'; +import { sleep } from '../../core'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { TerminalDeactivateLimitationPrompt } from '../../../client/terminals/envCollectionActivation/deactivatePrompt'; +import { PythonEnvType } from '../../../client/pythonEnvironments/base/info'; +import { TerminalShellType } from '../../../client/common/terminal/types'; + +suite('Terminal Deactivation Limitation Prompt', () => { + let shell: IApplicationShell; + let experimentService: IExperimentService; + let persistentStateFactory: IPersistentStateFactory; + let appEnvironment: IApplicationEnvironment; + let deactivatePrompt: TerminalDeactivateLimitationPrompt; + let terminalWriteEvent: EventEmitter; + let notificationEnabled: IPersistentState; + let browserService: IBrowserService; + let interpreterService: IInterpreterService; + const prompts = [Common.seeInstructions, Interpreters.deactivateDoneButton, Common.doNotShowAgain]; + const expectedMessage = Interpreters.terminalDeactivatePrompt; + + setup(async () => { + shell = mock(); + interpreterService = mock(); + experimentService = mock(); + persistentStateFactory = mock(); + appEnvironment = mock(); + when(appEnvironment.shell).thenReturn('bash'); + browserService = mock(); + notificationEnabled = mock>(); + terminalWriteEvent = new EventEmitter(); + when(persistentStateFactory.createGlobalPersistentState(anything(), true)).thenReturn( + instance(notificationEnabled), + ); + when(shell.onDidWriteTerminalData).thenReturn(terminalWriteEvent.event); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(true); + deactivatePrompt = new TerminalDeactivateLimitationPrompt( + instance(shell), + instance(persistentStateFactory), + [], + instance(interpreterService), + instance(browserService), + instance(appEnvironment), + instance(experimentService), + ); + }); + + test('Show notification when "deactivate" command is run when a virtual env is selected', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).once(); + }); + + test('When using cmd, do not show notification for the same', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + reset(appEnvironment); + when(appEnvironment.shell).thenReturn(TerminalShellType.commandPrompt); + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).once(); + }); + + test('When not in experiment, do not show notification for the same', async () => { + reset(experimentService); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(false); + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).never(); + }); + + test('Do not show notification if notification is disabled', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(false); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).never(); + }); + + test('Do not show notification when virtual env is not activated for terminal', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Conda, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).never(); + }); + + test("Disable notification if `Don't show again` is clicked", async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenReturn(Promise.resolve(Common.doNotShowAgain)); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(notificationEnabled.updateValue(false)).once(); + }); + + test('Disable notification if `Done, it works` is clicked', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenReturn( + Promise.resolve(Interpreters.deactivateDoneButton), + ); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(notificationEnabled.updateValue(false)).once(); + }); + + test('Open link to workaround if `See instructions` is clicked', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenReturn(Promise.resolve(Common.seeInstructions)); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).once(); + verify(browserService.launch(anything())).once(); + }); + + test('Do not perform any action if prompt is closed', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(notificationEnabled.value).thenReturn(true); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(shell.showWarningMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await deactivatePrompt.activate(); + terminalWriteEvent.fire({ data: 'Please deactivate me', terminal }); + await sleep(1); + + verify(shell.showWarningMessage(expectedMessage, ...prompts)).once(); + verify(notificationEnabled.updateValue(false)).never(); + verify(browserService.launch(anything())).never(); + }); +}); diff --git a/src/test/terminals/serviceRegistry.unit.test.ts b/src/test/terminals/serviceRegistry.unit.test.ts index 38a9a9744e91..816afa17cf88 100644 --- a/src/test/terminals/serviceRegistry.unit.test.ts +++ b/src/test/terminals/serviceRegistry.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import * as typemoq from 'typemoq'; +import { IExtensionActivationService, IExtensionSingleActivationService } from '../../client/activation/types'; import { IServiceManager } from '../../client/ioc/types'; import { TerminalAutoActivation } from '../../client/terminals/activation'; import { CodeExecutionManager } from '../../client/terminals/codeExecution/codeExecutionManager'; @@ -9,12 +10,16 @@ import { DjangoShellCodeExecutionProvider } from '../../client/terminals/codeExe import { CodeExecutionHelper } from '../../client/terminals/codeExecution/helper'; import { ReplProvider } from '../../client/terminals/codeExecution/repl'; import { TerminalCodeExecutionProvider } from '../../client/terminals/codeExecution/terminalCodeExecution'; +import { TerminalDeactivateLimitationPrompt } from '../../client/terminals/envCollectionActivation/deactivatePrompt'; +import { TerminalIndicatorPrompt } from '../../client/terminals/envCollectionActivation/indicatorPrompt'; +import { TerminalEnvVarCollectionService } from '../../client/terminals/envCollectionActivation/service'; import { registerTypes } from '../../client/terminals/serviceRegistry'; import { ICodeExecutionHelper, ICodeExecutionManager, ICodeExecutionService, ITerminalAutoActivation, + ITerminalEnvVarCollectionService, } from '../../client/terminals/types'; suite('Terminal - Service Registry', () => { @@ -27,6 +32,9 @@ suite('Terminal - Service Registry', () => { [ICodeExecutionService, ReplProvider, 'repl'], [ITerminalAutoActivation, TerminalAutoActivation], [ICodeExecutionService, TerminalCodeExecutionProvider, 'standard'], + [ITerminalEnvVarCollectionService, TerminalEnvVarCollectionService], + [IExtensionSingleActivationService, TerminalIndicatorPrompt], + [IExtensionSingleActivationService, TerminalDeactivateLimitationPrompt], ].forEach((args) => { if (args.length === 2) { services @@ -50,6 +58,14 @@ suite('Terminal - Service Registry', () => { .verifiable(typemoq.Times.once()); } }); + services + .setup((s) => + s.addBinding( + typemoq.It.is((v) => ITerminalEnvVarCollectionService === v), + typemoq.It.is((value) => IExtensionActivationService === value), + ), + ) + .verifiable(typemoq.Times.once()); registerTypes(services.object); diff --git a/typings/vscode-proposed/vscode.proposed.terminalDataWriteEvent.d.ts b/typings/vscode-proposed/vscode.proposed.terminalDataWriteEvent.d.ts new file mode 100644 index 000000000000..6913b862c70f --- /dev/null +++ b/typings/vscode-proposed/vscode.proposed.terminalDataWriteEvent.d.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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/78502 + // + // This API is still proposed but we don't intent on promoting it to stable due to problems + // around performance. See #145234 for a more likely API to get stabilized. + + export interface TerminalDataWriteEvent { + /** + * The {@link 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 terminal's child pseudo-device is written to (the shell). + * 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; + } +}