diff --git a/package.json b/package.json index 6342e1327205..37a3a4a5d139 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "quickPickSortByLabel", "testObserver", "quickPickItemTooltip", - "saveEditor" + "saveEditor", + "terminalDataWriteEvent" ], "author": { "name": "Microsoft Corporation" diff --git a/pythonFiles/deactivate b/pythonFiles/deactivate new file mode 100644 index 000000000000..6ede3da311a9 --- /dev/null +++ b/pythonFiles/deactivate @@ -0,0 +1,33 @@ +# Same as deactivate in "/bin/activate" +deactivate () { + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then + hash -r 2> /dev/null + fi + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + unset -f deactivate + fi +} + +# Initialize the variables required by deactivate function +_OLD_VIRTUAL_PS1="${PS1:-}" +_OLD_VIRTUAL_PATH="$PATH" +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" +fi diff --git a/pythonFiles/deactivate.csh b/pythonFiles/deactivate.csh new file mode 100644 index 000000000000..ef4d0d393897 --- /dev/null +++ b/pythonFiles/deactivate.csh @@ -0,0 +1,6 @@ +# Same as deactivate in "/bin/activate.csh" +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Initialize the variables required by deactivate function +set _OLD_VIRTUAL_PROMPT="$prompt" +set _OLD_VIRTUAL_PATH="$PATH" diff --git a/pythonFiles/deactivate.fish b/pythonFiles/deactivate.fish new file mode 100644 index 000000000000..c652a8c1e3d7 --- /dev/null +++ b/pythonFiles/deactivate.fish @@ -0,0 +1,36 @@ +# Same as deactivate in "/bin/activate.fish" +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$vscode_python_old_fish_prompt_OVERRIDE" + set -e vscode_python_old_fish_prompt_OVERRIDE + if functions -q vscode_python_old_fish_prompt + functions -e fish_prompt + functions -c vscode_python_old_fish_prompt fish_prompt + functions -e vscode_python_old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + functions -e deactivate + end +end + +# Initialize the variables required by deactivate function +set -gx _OLD_VIRTUAL_PATH $PATH +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + functions -c fish_prompt vscode_python_old_fish_prompt +end +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME +end diff --git a/pythonFiles/deactivate.ps1 b/pythonFiles/deactivate.ps1 new file mode 100644 index 000000000000..65dd80907d90 --- /dev/null +++ b/pythonFiles/deactivate.ps1 @@ -0,0 +1,31 @@ +# Same as deactivate in "Activate.ps1" +function global:deactivate ([switch]$NonDestructive) { + if (Test-Path function:_OLD_VIRTUAL_PROMPT) { + copy-item function:_OLD_VIRTUAL_PROMPT function:prompt + remove-item function:_OLD_VIRTUAL_PROMPT + } + if (Test-Path env:_OLD_VIRTUAL_PYTHONHOME) { + copy-item env:_OLD_VIRTUAL_PYTHONHOME env:PYTHONHOME + remove-item env:_OLD_VIRTUAL_PYTHONHOME + } + if (Test-Path env:_OLD_VIRTUAL_PATH) { + copy-item env:_OLD_VIRTUAL_PATH env:PATH + remove-item env:_OLD_VIRTUAL_PATH + } + if (Test-Path env:VIRTUAL_ENV) { + remove-item env:VIRTUAL_ENV + } + if (!$NonDestructive) { + remove-item function:deactivate + } +} + +# Initialize the variables required by deactivate function +if (! $env:VIRTUAL_ENV_DISABLE_PROMPT) { + function global:_OLD_VIRTUAL_PROMPT {""} + copy-item function:prompt function:_OLD_VIRTUAL_PROMPT +} +if (Test-Path env:PYTHONHOME) { + copy-item env:PYTHONHOME env:_OLD_VIRTUAL_PYTHONHOME +} +copy-item env:PATH env:_OLD_VIRTUAL_PATH 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/progressService.ts b/src/client/common/application/progressService.ts new file mode 100644 index 000000000000..fb19cad1136c --- /dev/null +++ b/src/client/common/application/progressService.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { ProgressOptions } from 'vscode'; +import { Deferred, createDeferred } from '../utils/async'; +import { IApplicationShell } from './types'; + +export class ProgressService { + private deferred: Deferred | undefined; + + constructor(private readonly shell: IApplicationShell) {} + + public showProgress(options: ProgressOptions): void { + if (!this.deferred) { + this.createProgress(options); + } + } + + public hideProgress(): void { + if (this.deferred) { + this.deferred.resolve(); + this.deferred = undefined; + } + } + + private createProgress(options: ProgressOptions) { + this.shell.withProgress(options, () => { + this.deferred = createDeferred(); + return this.deferred.promise; + }); + } +} 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/experiments/helpers.ts b/src/client/common/experiments/helpers.ts index bae96b222eb6..f6ae39d260f5 100644 --- a/src/client/common/experiments/helpers.ts +++ b/src/client/common/experiments/helpers.ts @@ -7,10 +7,12 @@ import { env, workspace } from 'vscode'; import { IExperimentService } from '../types'; import { TerminalEnvVarActivation } from './groups'; import { isTestExecution } from '../constants'; +import { traceInfo } from '../../logging'; export function inTerminalEnvVarExperiment(experimentService: IExperimentService): boolean { - if (!isTestExecution() && workspace.workspaceFile && env.remoteName) { + if (!isTestExecution() && env.remoteName && workspace.workspaceFolders && workspace.workspaceFolders.length > 1) { // TODO: Remove this if statement once https://github.com/microsoft/vscode/issues/180486 is fixed. + traceInfo('Not enabling terminal env var experiment in multiroot remote workspaces'); return false; } if (!experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)) { diff --git a/src/client/common/platform/fs-paths.ts b/src/client/common/platform/fs-paths.ts index 2d46fca98526..17df7507f7d9 100644 --- a/src/client/common/platform/fs-paths.ts +++ b/src/client/common/platform/fs-paths.ts @@ -3,6 +3,7 @@ import * as nodepath from 'path'; import { getSearchPathEnvVarNames } from '../utils/exec'; +import * as fs from 'fs-extra'; import { getOSType, OSType } from '../utils/platform'; import { IExecutables, IFileSystemPaths, IFileSystemPathUtils } from './types'; @@ -170,3 +171,22 @@ export function isParentPath(filePath: string, parentPath: string): boolean { export function arePathsSame(path1: string, path2: string): boolean { return normCasePath(path1) === normCasePath(path2); } + +export async function copyFile(src: string, dest: string): Promise { + const destDir = nodepath.dirname(dest); + if (!(await fs.pathExists(destDir))) { + await fs.mkdirp(destDir); + } + + await fs.copy(src, dest, { + overwrite: true, + }); +} + +export function pathExists(absPath: string): Promise { + return fs.pathExists(absPath); +} + +export function createFile(filename: string): Promise { + return fs.createFile(filename); +} diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index b5d1721d14fa..56818afa376d 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -61,6 +61,7 @@ export namespace Common { export const noIWillDoItLater = l10n.t('No, I will do it later'); export const notNow = l10n.t('Not now'); export const doNotShowAgain = l10n.t("Don't show again"); + export const editSomething = l10n.t('Edit {0}'); export const reload = l10n.t('Reload'); export const moreInfo = l10n.t('More Info'); export const learnMore = l10n.t('Learn more'); @@ -198,6 +199,11 @@ 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 terminalDeactivateProgress = l10n.t('Editing {0}...'); + export const restartingTerminal = l10n.t('Restarting terminal and deactivating...'); + 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 by appending a line to "{0}". Be sure to restart the shell afterward. [Learn more](https://aka.ms/AAmx2ft).', + ); 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/interpreter/virtualEnvs/activatedEnvLaunch.ts b/src/client/interpreter/virtualEnvs/activatedEnvLaunch.ts index 468c2dc72a01..b4dcfe36e095 100644 --- a/src/client/interpreter/virtualEnvs/activatedEnvLaunch.ts +++ b/src/client/interpreter/virtualEnvs/activatedEnvLaunch.ts @@ -91,7 +91,10 @@ export class ActivatedEnvironmentLaunch implements IActivatedEnvironmentLaunch { } if (process.env.VSCODE_CLI !== '1') { // We only want to select the interpreter if VS Code was launched from the command line. - traceVerbose('VS Code was not launched from the command line, not selecting activated interpreter'); + traceVerbose( + 'VS Code was not launched from the command line, not selecting activated interpreter', + JSON.stringify(process.env, undefined, 4), + ); return undefined; } const prefix = await this.getPrefixOfSelectedActivatedEnv(); diff --git a/src/client/terminals/envCollectionActivation/deactivatePrompt.ts b/src/client/terminals/envCollectionActivation/deactivatePrompt.ts new file mode 100644 index 000000000000..43d1e77957bc --- /dev/null +++ b/src/client/terminals/envCollectionActivation/deactivatePrompt.ts @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import { Position, Uri, WorkspaceEdit, Range, TextEditorRevealType, ProgressLocation, Terminal } from 'vscode'; +import { + IApplicationEnvironment, + IApplicationShell, + IDocumentManager, + ITerminalManager, +} from '../../common/application/types'; +import { 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 { traceError } from '../../logging'; +import { shellExec } from '../../common/process/rawProcessApis'; +import { sleep } from '../../common/utils/async'; +import { getDeactivateShellInfo } from './deactivateScripts'; +import { isTestExecution } from '../../common/constants'; +import { ProgressService } from '../../common/application/progressService'; +import { copyFile, createFile, pathExists } from '../../common/platform/fs-paths'; +import { getOSType, OSType } from '../../common/utils/platform'; + +export const terminalDeactivationPromptKey = 'TERMINAL_DEACTIVATION_PROMPT_KEY'; +@injectable() +export class TerminalDeactivateLimitationPrompt implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + + private terminalProcessId: number | undefined; + + private readonly progressService: ProgressService; + + 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(IApplicationEnvironment) private readonly appEnvironment: IApplicationEnvironment, + @inject(IDocumentManager) private readonly documentManager: IDocumentManager, + @inject(ITerminalManager) private readonly terminalManager: ITerminalManager, + @inject(IExperimentService) private readonly experimentService: IExperimentService, + ) { + this.progressService = new ProgressService(this.appShell); + } + + public async activate(): Promise { + if (!inTerminalEnvVarExperiment(this.experimentService)) { + return; + } + if (!isTestExecution()) { + // Avoid showing prompt until startup completes. + await sleep(5000); + } + this.disposableRegistry.push( + this.appShell.onDidWriteTerminalData(async (e) => { + if (!e.data.includes('deactivate')) { + return; + } + let shellType = identifyShellFromShellPath(this.appEnvironment.shell); + if (shellType === TerminalShellType.commandPrompt) { + return; + } + if (getOSType() === OSType.OSX && shellType === TerminalShellType.bash) { + // On macOS, sometimes bash is overriden by OS to actually launch zsh, so we need to execute inside + // the shell to get the correct shell type. + const shell = await shellExec('echo $SHELL', { shell: this.appEnvironment.shell }).then((output) => + output.stdout.trim(), + ); + shellType = identifyShellFromShellPath(shell); + } + 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(shellType, terminal).catch((ex) => traceError('Deactivate prompt failed', ex)); + }), + ); + } + + public async _notifyUsers(shellType: TerminalShellType, terminal: Terminal): Promise { + const notificationPromptEnabled = this.persistentStateFactory.createGlobalPersistentState( + `${terminalDeactivationPromptKey}-${shellType}`, + true, + ); + if (!notificationPromptEnabled.value) { + const processId = await terminal.processId; + if (processId && this.terminalProcessId === processId) { + // Existing terminal needs to be restarted for changes to take effect. + await this.forceRestartShell(terminal); + } + return; + } + const scriptInfo = getDeactivateShellInfo(shellType); + if (!scriptInfo) { + // Shell integration is not supported for these shells, in which case this workaround won't work. + return; + } + const { initScript, source, destination } = scriptInfo; + const prompts = [Common.editSomething.format(initScript.displayName), Common.doNotShowAgain]; + const selection = await this.appShell.showWarningMessage( + Interpreters.terminalDeactivatePrompt.format(initScript.displayName), + ...prompts, + ); + if (!selection) { + return; + } + if (selection === prompts[0]) { + this.progressService.showProgress({ + location: ProgressLocation.Window, + title: Interpreters.terminalDeactivateProgress.format(initScript.displayName), + }); + await copyFile(source, destination); + await this.openScriptWithEdits(initScript.command, initScript.contents); + await notificationPromptEnabled.updateValue(false); + this.progressService.hideProgress(); + this.terminalProcessId = await terminal.processId; + } + if (selection === prompts[1]) { + await notificationPromptEnabled.updateValue(false); + } + } + + private async openScriptWithEdits(command: string, content: string) { + const document = await this.openScript(command); + const hookMarker = 'VSCode venv deactivate hook'; + content = ` +# >>> ${hookMarker} >>> +${content} +# <<< ${hookMarker} <<<`; + // If script already has the hook, don't add it again. + const editor = await this.documentManager.showTextDocument(document); + if (document.getText().includes(hookMarker)) { + return; + } + const editorEdit = new WorkspaceEdit(); + editorEdit.insert(document.uri, new Position(document.lineCount, 0), content); + await this.documentManager.applyEdit(editorEdit); + // Reveal the edits. + editor.revealRange( + new Range(new Position(document.lineCount - 3, 0), new Position(document.lineCount, 0)), + TextEditorRevealType.AtTop, + ); + } + + private async openScript(command: string) { + const initScriptPath = await this.getPathToScript(command); + if (!(await pathExists(initScriptPath))) { + await createFile(initScriptPath); + } + const document = await this.documentManager.openTextDocument(initScriptPath); + return document; + } + + private async getPathToScript(command: string) { + return shellExec(command, { shell: this.appEnvironment.shell }).then((output) => output.stdout.trim()); + } + + public async forceRestartShell(terminal: Terminal): Promise { + terminal.dispose(); + terminal = this.terminalManager.createTerminal({ + message: Interpreters.restartingTerminal, + }); + terminal.show(true); + terminal.sendText('deactivate'); + } +} diff --git a/src/client/terminals/envCollectionActivation/deactivateScripts.ts b/src/client/terminals/envCollectionActivation/deactivateScripts.ts new file mode 100644 index 000000000000..34917e44bbdf --- /dev/null +++ b/src/client/terminals/envCollectionActivation/deactivateScripts.ts @@ -0,0 +1,108 @@ +/* eslint-disable no-case-declarations */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { _SCRIPTS_DIR } from '../../common/process/internal/scripts/constants'; +import { TerminalShellType } from '../../common/terminal/types'; + +type DeactivateShellInfo = { + /** + * Full path to source deactivate script to copy. + */ + source: string; + /** + * Full path to destination to copy deactivate script to. + */ + destination: string; + initScript: { + /** + * Display name of init script for the shell. + */ + displayName: string; + /** + * Command to run in shell to output the full path to init script. + */ + command: string; + /** + * Contents to add to init script. + */ + contents: string; + }; +}; + +// eslint-disable-next-line global-require +const untildify: (value: string) => string = require('untildify'); + +export function getDeactivateShellInfo(shellType: TerminalShellType): DeactivateShellInfo | undefined { + switch (shellType) { + case TerminalShellType.bash: + return buildInfo( + 'deactivate', + { + displayName: '~/.bashrc', + path: '~/.bashrc', + }, + `source {0}`, + ); + case TerminalShellType.powershellCore: + case TerminalShellType.powershell: + return buildInfo( + 'deactivate.ps1', + { + displayName: 'Powershell Profile', + path: '$Profile', + }, + `& "{0}"`, + ); + case TerminalShellType.zsh: + return buildInfo( + 'deactivate', + { + displayName: '~/.zshrc', + path: '~/.zshrc', + }, + `source {0}`, + ); + case TerminalShellType.fish: + return buildInfo( + 'deactivate.fish', + { + displayName: 'config.fish', + path: '$__fish_config_dir/config.fish', + }, + `source {0}`, + ); + case TerminalShellType.cshell: + return buildInfo( + 'deactivate.csh', + { + displayName: '~/.cshrc', + path: '~/.cshrc', + }, + `source {0}`, + ); + default: + return undefined; + } +} + +function buildInfo( + deactivate: string, + initScript: { + path: string; + displayName: string; + }, + scriptCommandFormat: string, +) { + const scriptPath = path.join('~', '.vscode-python', deactivate); + return { + source: path.join(_SCRIPTS_DIR, deactivate), + destination: untildify(scriptPath), + initScript: { + displayName: initScript.displayName, + command: `echo ${initScript.path}`, + contents: scriptCommandFormat.format(scriptPath), + }, + }; +} diff --git a/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts b/src/client/terminals/envCollectionActivation/indicatorPrompt.ts similarity index 90% rename from src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts rename to src/client/terminals/envCollectionActivation/indicatorPrompt.ts index c8aea205a32a..bc4c3cc90fc0 100644 --- a/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts +++ b/src/client/terminals/envCollectionActivation/indicatorPrompt.ts @@ -14,15 +14,17 @@ 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'; +import { sleep } from '../../common/utils/async'; +import { isTestExecution } from '../../common/constants'; 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( @@ -42,6 +44,10 @@ export class TerminalEnvVarCollectionPrompt implements IExtensionSingleActivatio if (!inTerminalEnvVarExperiment(this.experimentService)) { return; } + if (!isTestExecution()) { + // Avoid showing prompt until startup completes. + await sleep(5000); + } this.disposableRegistry.push( this.terminalManager.onDidOpenTerminal(async (terminal) => { const cwd = diff --git a/src/client/interpreter/activation/terminalEnvVarCollectionService.ts b/src/client/terminals/envCollectionActivation/service.ts similarity index 87% rename from src/client/interpreter/activation/terminalEnvVarCollectionService.ts rename to src/client/terminals/envCollectionActivation/service.ts index 92e97c95e468..e08a1f7e72c3 100644 --- a/src/client/interpreter/activation/terminalEnvVarCollectionService.ts +++ b/src/client/terminals/envCollectionActivation/service.ts @@ -4,12 +4,12 @@ import * as path from 'path'; import { inject, injectable } from 'inversify'; import { - ProgressOptions, - ProgressLocation, MarkdownString, WorkspaceFolder, GlobalEnvironmentVariableCollection, EnvironmentVariableScope, + EnvironmentVariableMutatorOptions, + ProgressLocation, } from 'vscode'; import { pathExists } from 'fs-extra'; import { IExtensionActivationService } from '../../activation/types'; @@ -25,12 +25,11 @@ import { IConfigurationService, IPathUtils, } from '../../common/types'; -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 { traceError, traceVerbose, traceWarn } from '../../logging'; +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 +37,9 @@ 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'; +import { ShellIntegrationShells } from './shellIntegration'; +import { ProgressService } from '../../common/application/progressService'; @injectable() export class TerminalEnvVarCollectionService implements IExtensionActivationService, ITerminalEnvVarCollectionService { @@ -55,8 +57,6 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ TerminalShellType.fish, ]; - private deferred: Deferred | undefined; - private registeredOnce = false; /** @@ -64,6 +64,8 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ */ private processEnvVars: EnvironmentVariables | undefined; + private readonly progressService: ProgressService; + private separator: string; constructor( @@ -80,6 +82,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ @inject(IPathUtils) private readonly pathUtils: IPathUtils, ) { this.separator = platform.osType === OSType.Windows ? ';' : ':'; + this.progressService = new ProgressService(this.shell); } public async activate(resource: Resource): Promise { @@ -126,9 +129,12 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ } public async _applyCollection(resource: Resource, shell?: string): Promise { - this.showProgress(); + this.progressService.showProgress({ + location: ProgressLocation.Window, + title: Interpreters.activatingTerminals, + }); await this._applyCollectionImpl(resource, shell); - this.hideProgress(); + this.progressService.hideProgress(); } private async _applyCollectionImpl(resource: Resource, shell = this.applicationEnvironment.shell): Promise { @@ -171,6 +177,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ // PS1 in some cases is a shell variable (not an env variable) so "env" might not contain it, calculate it in that case. env.PS1 = await this.getPS1(shell, resource, env); + const prependOptions = this.getPrependOptions(); // Clear any previously set env vars from collection envVarCollection.clear(); @@ -185,10 +192,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ if (key === 'PS1') { // We cannot have the full PS1 without executing in terminal, which we do not. Hence prepend it. traceVerbose(`Prepending environment variable ${key} in collection with ${value}`); - envVarCollection.prepend(key, value, { - applyAtShellIntegration: true, - applyAtProcessCreation: false, - }); + envVarCollection.prepend(key, value, prependOptions); return; } if (key === 'PATH') { @@ -198,19 +202,13 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ const prependedPart = env.PATH.slice(0, -processEnv.PATH.length); value = prependedPart; traceVerbose(`Prepending environment variable ${key} in collection with ${value}`); - envVarCollection.prepend(key, value, { - applyAtShellIntegration: true, - applyAtProcessCreation: true, - }); + envVarCollection.prepend(key, value, prependOptions); } else { if (!value.endsWith(this.separator)) { value = value.concat(this.separator); } traceVerbose(`Prepending environment variable ${key} in collection to ${value}`); - envVarCollection.prepend(key, value, { - applyAtShellIntegration: true, - applyAtProcessCreation: true, - }); + envVarCollection.prepend(key, value, prependOptions); } return; } @@ -272,9 +270,7 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ // PS1 should be set but no PS1 was set. return; } - const config = this.workspaceService - .getConfiguration('terminal') - .get('integrated.shellIntegration.enabled'); + const config = this.isShellIntegrationActive(); if (!config) { traceVerbose('PS1 is not set when shell integration is disabled.'); return; @@ -329,6 +325,36 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ } } + private getPrependOptions(): EnvironmentVariableMutatorOptions { + const isActive = this.isShellIntegrationActive(); + // Ideally we would want to prepend exactly once, either at shell integration or process creation. + // TODO: Stop prepending altogether once https://github.com/microsoft/vscode/issues/145234 is available. + return isActive + ? { + applyAtShellIntegration: true, + applyAtProcessCreation: false, + } + : { + applyAtShellIntegration: true, // Takes care of false negatives in case manual integration is being used. + applyAtProcessCreation: true, + }; + } + + private isShellIntegrationActive(): boolean { + const isEnabled = this.workspaceService + .getConfiguration('terminal') + .get('integrated.shellIntegration.enabled')!; + if ( + isEnabled && + ShellIntegrationShells.includes(identifyShellFromShellPath(this.applicationEnvironment.shell)) + ) { + // Unfortunately shell integration could still've failed in remote scenarios, we can't know for sure: + // https://code.visualstudio.com/docs/terminal/shell-integration#_automatic-script-injection + return true; + } + return false; + } + private getEnvironmentVariableCollection(scope: EnvironmentVariableScope = {}) { const envVarCollection = this.context.environmentVariableCollection as GlobalEnvironmentVariableCollection; return envVarCollection.getScoped(scope); @@ -345,32 +371,6 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ } return workspaceFolder; } - - @traceDecoratorVerbose('Display activating terminals') - private showProgress(): void { - if (!this.deferred) { - this.createProgress(); - } - } - - @traceDecoratorVerbose('Hide activating terminals') - private hideProgress(): void { - if (this.deferred) { - this.deferred.resolve(); - this.deferred = undefined; - } - } - - private createProgress() { - const progressOptions: ProgressOptions = { - location: ProgressLocation.Window, - title: Interpreters.activatingTerminals, - }; - this.shell.withProgress(progressOptions, () => { - this.deferred = createDeferred(); - return this.deferred.promise; - }); - } } function shouldPS1BeSet(type: PythonEnvType | undefined, env: EnvironmentVariables): boolean { diff --git a/src/client/terminals/envCollectionActivation/shellIntegration.ts b/src/client/terminals/envCollectionActivation/shellIntegration.ts new file mode 100644 index 000000000000..1be2501595a4 --- /dev/null +++ b/src/client/terminals/envCollectionActivation/shellIntegration.ts @@ -0,0 +1,13 @@ +import { TerminalShellType } from '../../common/terminal/types'; + +/** + * This is a list of shells which support shell integration: + * https://code.visualstudio.com/docs/terminal/shell-integration + */ +export const ShellIntegrationShells = [ + TerminalShellType.powershell, + TerminalShellType.powershellCore, + TerminalShellType.bash, + TerminalShellType.zsh, + TerminalShellType.fish, +]; 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 48e39d4e1c81..ba30b8f6d47d 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/common/application/progressService.unit.test.ts b/src/test/common/application/progressService.unit.test.ts new file mode 100644 index 000000000000..b9c49ccb4060 --- /dev/null +++ b/src/test/common/application/progressService.unit.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { assert, expect } from 'chai'; +import { anything, capture, instance, mock, when } from 'ts-mockito'; +import { CancellationToken, Progress, ProgressLocation, ProgressOptions } from 'vscode'; +import { ApplicationShell } from '../../../client/common/application/applicationShell'; +import { ProgressService } from '../../../client/common/application/progressService'; +import { IApplicationShell } from '../../../client/common/application/types'; +import { createDeferred, createDeferredFromPromise, Deferred, sleep } from '../../../client/common/utils/async'; + +type ProgressTask = ( + progress: Progress<{ message?: string; increment?: number }>, + token: CancellationToken, +) => Thenable; + +suite('Progress Service', () => { + let refreshDeferred: Deferred; + let shell: ApplicationShell; + let progressService: ProgressService; + setup(() => { + refreshDeferred = createDeferred(); + shell = mock(); + progressService = new ProgressService(instance(shell)); + }); + teardown(() => { + refreshDeferred.resolve(); + }); + test('Display discovering message when refreshing interpreters for the first time', async () => { + when(shell.withProgress(anything(), anything())).thenResolve(); + const expectedOptions = { title: 'message', location: ProgressLocation.Window }; + + progressService.showProgress(expectedOptions); + + const options = capture(shell.withProgress as never).last()[0] as ProgressOptions; + assert.deepEqual(options, expectedOptions); + }); + + test('Progress message is hidden when loading has completed', async () => { + when(shell.withProgress(anything(), anything())).thenResolve(); + const options = { title: 'message', location: ProgressLocation.Window }; + progressService.showProgress(options); + + const callback = capture(shell.withProgress as never).last()[1] as ProgressTask; + const promise = callback(undefined as never, undefined as never); + const deferred = createDeferredFromPromise(promise as Promise); + await sleep(1); + expect(deferred.completed).to.be.equal(false, 'Progress disappeared before hiding it'); + progressService.hideProgress(); + await sleep(1); + expect(deferred.completed).to.be.equal(true, 'Progress did not disappear'); + }); +}); diff --git a/src/test/common/platform/fs-temp.functional.test.ts b/src/test/common/platform/fs-temp.functional.test.ts index 256d52a81cf0..9fb4fe189b96 100644 --- a/src/test/common/platform/fs-temp.functional.test.ts +++ b/src/test/common/platform/fs-temp.functional.test.ts @@ -5,7 +5,7 @@ import { expect, use } from 'chai'; import * as fs from 'fs-extra'; import { TemporaryFileSystem } from '../../../client/common/platform/fs-temp'; import { TemporaryFile } from '../../../client/common/platform/types'; -import { assertDoesNotExist, assertExists, FSFixture, WINDOWS } from './utils'; +import { assertDoesNotExist, assertExists, FSFixture } from './utils'; const assertArrays = require('chai-arrays'); use(require('chai-as-promised')); @@ -56,21 +56,6 @@ suite('FileSystem - TemporaryFileSystem', () => { expect(filename1).to.not.equal(filename2); }); - test('Ensure writing to a temp file is supported via file stream', async function () { - if (WINDOWS) { - this.skip(); - } - const tempfile = await createFile('.tmp'); - const stream = fs.createWriteStream(tempfile.filePath); - fix.addCleanup(() => stream.destroy()); - const data = '...'; - - stream.write(data, 'utf8'); - - const actual = await fs.readFile(tempfile.filePath, 'utf8'); - expect(actual).to.equal(data); - }); - test('Ensure chmod works against a temporary file', async () => { // Note that on Windows chmod is a noop. const tempfile = await createFile('.tmp'); diff --git a/src/test/common/terminals/environmentActivationProviders/terminalActivation.testvirtualenvs.ts b/src/test/common/terminals/environmentActivationProviders/terminalActivation.testvirtualenvs.ts index cabf293ba958..58ae464d0113 100644 --- a/src/test/common/terminals/environmentActivationProviders/terminalActivation.testvirtualenvs.ts +++ b/src/test/common/terminals/environmentActivationProviders/terminalActivation.testvirtualenvs.ts @@ -63,7 +63,8 @@ suite('Activation of Environments in Terminal', () => { await terminalSettings.update('integrated.defaultProfile.linux', 'bash', vscode.ConfigurationTarget.Global); }); - setup(async () => { + setup(async function () { + this.skip(); // https://github.com/microsoft/vscode-python/issues/22264 await initializeTest(); outputFile = path.join( EXTENSION_ROOT_DIR_FOR_TESTS, 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..88b9c978854c 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'; @@ -331,7 +331,7 @@ suite('Terminal Environment Variable Collection Service', () => { verify(collection.clear()).once(); verify(collection.prepend('PATH', prependedPart, anything())).once(); verify(collection.replace('PATH', anything(), anything())).never(); - assert.deepEqual(opts, { applyAtProcessCreation: true, applyAtShellIntegration: true }); + assert.deepEqual(opts, { applyAtProcessCreation: false, applyAtShellIntegration: true }); }); test('Prepend full PATH with separator otherwise', async () => { @@ -364,7 +364,7 @@ suite('Terminal Environment Variable Collection Service', () => { verify(collection.clear()).once(); verify(collection.prepend('PATH', `${finalPath}${separator}`, anything())).once(); verify(collection.replace('PATH', anything(), anything())).never(); - assert.deepEqual(opts, { applyAtProcessCreation: true, applyAtShellIntegration: true }); + assert.deepEqual(opts, { applyAtProcessCreation: false, applyAtShellIntegration: true }); }); test('Verify envs are not applied if env activation is disabled', async () => { 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..f775241abb32 --- /dev/null +++ b/src/test/terminals/envCollectionActivation/deactivatePrompt.unit.test.ts @@ -0,0 +1,271 @@ +// 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, TextDocument, TextEditor, Uri } from 'vscode'; +import * as sinon from 'sinon'; +import { expect } from 'chai'; +import { + IApplicationEnvironment, + IApplicationShell, + IDocumentManager, + ITerminalManager, +} from '../../../client/common/application/types'; +import { 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'; +import * as processApi from '../../../client/common/process/rawProcessApis'; +import * as fsapi from '../../../client/common/platform/fs-paths'; +import { noop } from '../../../client/common/utils/misc'; + +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 interpreterService: IInterpreterService; + let terminalManager: ITerminalManager; + let documentManager: IDocumentManager; + const prompts = [Common.editSomething.format('~/.bashrc'), Common.doNotShowAgain]; + const expectedMessage = Interpreters.terminalDeactivatePrompt.format('~/.bashrc'); + const initScriptPath = 'home/node/.bashrc'; + const resource = Uri.file('a'); + let terminal: Terminal; + + setup(async () => { + const activeEditorEvent = new EventEmitter(); + const document = ({ + uri: Uri.file(''), + getText: () => '', + } as unknown) as TextDocument; + sinon.stub(processApi, 'shellExec').callsFake(async (command: string) => { + if (command !== 'echo ~/.bashrc') { + throw new Error(`Unexpected command: ${command}`); + } + await sleep(1500); + return { stdout: initScriptPath }; + }); + documentManager = mock(); + terminalManager = mock(); + terminal = ({ + creationOptions: { cwd: resource }, + processId: Promise.resolve(1), + dispose: noop, + show: noop, + sendText: noop, + } as unknown) as Terminal; + when(terminalManager.createTerminal(anything())).thenReturn(terminal); + when(documentManager.openTextDocument(initScriptPath)).thenReturn(Promise.resolve(document)); + when(documentManager.onDidChangeActiveTextEditor).thenReturn(activeEditorEvent.event); + shell = mock(); + interpreterService = mock(); + experimentService = mock(); + persistentStateFactory = mock(); + appEnvironment = mock(); + when(appEnvironment.shell).thenReturn('bash'); + 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(appEnvironment), + instance(documentManager), + instance(terminalManager), + instance(experimentService), + ); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Show notification when "deactivate" command is run when a virtual env is selected', async () => { + 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 () => { + 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)).never(); + }); + + test('When not in experiment, do not show notification for the same', async () => { + reset(experimentService); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(false); + + 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 () => { + 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 () => { + 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 () => { + 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('Edit script correctly if `Edit