diff --git a/src/common/localize.ts b/src/common/localize.ts index 5a23d8d4..d3ce5c10 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -15,6 +15,7 @@ export namespace Common { export const ok = l10n.t('Ok'); export const quickCreate = l10n.t('Quick Create'); export const installPython = l10n.t('Install Python'); + export const dontShowAgain = l10n.t("Don't Show Again"); export const dontAskAgain = l10n.t("Don't ask again"); } @@ -223,6 +224,9 @@ export namespace ActivationStrings { Commands.viewLogs, ); export const activatingEnvironment = l10n.t('Activating environment'); + export const envFileInjectionDisabled = l10n.t( + 'An environment file is configured but terminal environment injection is disabled. Enable "python.terminal.useEnvFile" to use environment variables from .env files in terminals.', + ); } export namespace UvInstallStrings { diff --git a/src/features/terminal/terminalEnvVarInjector.ts b/src/features/terminal/terminalEnvVarInjector.ts index 0a8d685e..1af2269c 100644 --- a/src/features/terminal/terminalEnvVarInjector.ts +++ b/src/features/terminal/terminalEnvVarInjector.ts @@ -7,15 +7,19 @@ import { Disposable, EnvironmentVariableScope, GlobalEnvironmentVariableCollection, - window, workspace, WorkspaceFolder, } from 'vscode'; -import { traceError, traceVerbose } from '../../common/logging'; +import { ActivationStrings, Common } from '../../common/localize'; +import { traceError, traceLog, traceVerbose } from '../../common/logging'; +import { getGlobalPersistentState } from '../../common/persistentState'; import { resolveVariables } from '../../common/utils/internalVariables'; +import { showInformationMessage } from '../../common/window.apis'; import { getConfiguration, getWorkspaceFolder } from '../../common/workspace.apis'; import { EnvVarManager } from '../execution/envVariableManager'; +export const ENV_FILE_NOTIFICATION_DONT_SHOW_KEY = 'python-envs:terminal:ENV_FILE_NOTIFICATION_DONT_SHOW'; + /** * Manages injection of workspace-specific environment variables into VS Code terminals * using the GlobalEnvironmentVariableCollection API. @@ -65,9 +69,9 @@ export class TerminalEnvVarInjector implements Disposable { // Only show notification when env vars change and we have an env file but injection is disabled if (!useEnvFile && envFilePath) { - window.showInformationMessage( - 'An environment file is configured but terminal environment injection is disabled. Enable "python.terminal.useEnvFile" to use environment variables from .env files in terminals.', - ); + this.showEnvFileNotification().catch((error) => { + traceError('Failed to show env file notification:', error); + }); } if (args.changeType === 2) { @@ -208,6 +212,23 @@ export class TerminalEnvVarInjector implements Disposable { } } + /** + * Show a notification about env file injection being disabled, with a "Don't Show Again" option. + */ + private async showEnvFileNotification(): Promise { + const state = await getGlobalPersistentState(); + const dontShow = await state.get(ENV_FILE_NOTIFICATION_DONT_SHOW_KEY); + if (dontShow) { + return; + } + + const result = await showInformationMessage(ActivationStrings.envFileInjectionDisabled, Common.dontShowAgain); + if (result === Common.dontShowAgain) { + await state.set(ENV_FILE_NOTIFICATION_DONT_SHOW_KEY, true); + traceLog(`User selected "Don't Show Again" for env file notification`); + } + } + /** * Dispose of the injector and clean up resources. */ diff --git a/src/test/features/terminalEnvVarInjector.unit.test.ts b/src/test/features/terminalEnvVarInjector.unit.test.ts index 0d5a1050..9f2b70f9 100644 --- a/src/test/features/terminalEnvVarInjector.unit.test.ts +++ b/src/test/features/terminalEnvVarInjector.unit.test.ts @@ -13,9 +13,15 @@ import { WorkspaceFolder, workspace, } from 'vscode'; +import { Common } from '../../common/localize'; +import * as persistentState from '../../common/persistentState'; +import * as windowApis from '../../common/window.apis'; import * as workspaceApis from '../../common/workspace.apis'; import { EnvVarManager } from '../../features/execution/envVariableManager'; -import { TerminalEnvVarInjector } from '../../features/terminal/terminalEnvVarInjector'; +import { + ENV_FILE_NOTIFICATION_DONT_SHOW_KEY, + TerminalEnvVarInjector, +} from '../../features/terminal/terminalEnvVarInjector'; interface MockScopedCollection { clear: sinon.SinonStub; @@ -307,4 +313,125 @@ suite('TerminalEnvVarInjector', () => { } }); }); + + suite('env file notification with Don\'t Show Again', () => { + let envChangeCallback: ((args: { uri?: Uri; changeType: number }) => void) | undefined; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; + let showInfoMessageStub: sinon.SinonStub; + + setup(() => { + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }; + sinon.stub(persistentState, 'getGlobalPersistentState').resolves(mockState); + showInfoMessageStub = sinon.stub(windowApis, 'showInformationMessage'); + + // Capture the onDidChangeEnvironmentVariables listener + envVarManager.reset(); + envVarManager + .setup((m) => m.onDidChangeEnvironmentVariables) + .returns(() => { + return (listener: (args: { uri?: Uri; changeType: number }) => void): Disposable => { + envChangeCallback = listener; + return new Disposable(() => {}); + }; + }); + envVarManager + .setup((m) => m.getEnvironmentVariables(typeMoq.It.isAny())) + .returns(() => Promise.resolve({})); + + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(testWorkspaceFolder); + }); + + test('should show notification with Don\'t Show Again button when env file configured but injection disabled', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: false, envFilePath: '${workspaceFolder}/.env' }) as WorkspaceConfiguration, + ); + mockState.get.resolves(false); + showInfoMessageStub.resolves(undefined); + + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(envChangeCallback, 'Event handler should be registered'); + envChangeCallback!({ uri: Uri.file(testWorkspacePath), changeType: 1 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(showInfoMessageStub.calledOnce, 'Should show notification'); + assert.ok( + showInfoMessageStub.calledWith(sinon.match.string, Common.dontShowAgain), + 'Should include Don\'t Show Again button', + ); + }); + + test('should not show notification when Don\'t Show Again was previously selected', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: false, envFilePath: '${workspaceFolder}/.env' }) as WorkspaceConfiguration, + ); + mockState.get.resolves(true); + + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(envChangeCallback, 'Event handler should be registered'); + envChangeCallback!({ uri: Uri.file(testWorkspacePath), changeType: 1 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(showInfoMessageStub.notCalled, 'Should not show notification when dismissed'); + }); + + test('should persist preference when Don\'t Show Again is clicked', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: false, envFilePath: '${workspaceFolder}/.env' }) as WorkspaceConfiguration, + ); + mockState.get.resolves(false); + showInfoMessageStub.resolves(Common.dontShowAgain); + + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(envChangeCallback, 'Event handler should be registered'); + envChangeCallback!({ uri: Uri.file(testWorkspacePath), changeType: 1 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok( + mockState.set.calledWith(ENV_FILE_NOTIFICATION_DONT_SHOW_KEY, true), + 'Should persist Don\'t Show Again preference', + ); + }); + + test('should not show notification when useEnvFile is true', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: true, envFilePath: '${workspaceFolder}/.env' }) as WorkspaceConfiguration, + ); + mockState.get.resolves(false); + + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(envChangeCallback, 'Event handler should be registered'); + envChangeCallback!({ uri: Uri.file(testWorkspacePath), changeType: 1 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(showInfoMessageStub.notCalled, 'Should not show notification when useEnvFile is true'); + }); + + test('should not show notification when no envFile is configured', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: false }) as WorkspaceConfiguration, + ); + mockState.get.resolves(false); + + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(envChangeCallback, 'Event handler should be registered'); + envChangeCallback!({ uri: Uri.file(testWorkspacePath), changeType: 1 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.ok(showInfoMessageStub.notCalled, 'Should not show notification when no envFile configured'); + }); + }); });