Skip to content

Commit 192e363

Browse files
Preserve terminal activation when environment files change (Fixes #1863)
Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent 253eed7 commit 192e363

2 files changed

Lines changed: 112 additions & 22 deletions

File tree

‎src/features/terminal/terminalEnvVarInjector.ts‎

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,9 @@ export class TerminalEnvVarInjector implements Disposable {
7676
});
7777
}
7878

79-
if (args.changeType === 2) {
80-
// FileChangeType.Deleted
81-
this.clearWorkspaceVariables(affectedWorkspace);
82-
} else {
83-
this.updateEnvironmentVariables(affectedWorkspace).catch((error) => {
84-
traceError('Failed to update environment variables:', error);
85-
});
86-
}
79+
this.updateEnvironmentVariables(affectedWorkspace).catch((error) => {
80+
traceError('Failed to update environment variables:', error);
81+
});
8782
}),
8883
);
8984

@@ -170,8 +165,13 @@ export class TerminalEnvVarInjector implements Disposable {
170165
: undefined;
171166
const defaultEnvFilePath: string = path.join(workspaceUri.fsPath, '.env');
172167

173-
let activeEnvFilePath: string = resolvedEnvFilePath || defaultEnvFilePath;
174-
if (activeEnvFilePath && (await fse.pathExists(activeEnvFilePath))) {
168+
const activeEnvFilePath =
169+
resolvedEnvFilePath && (await fse.pathExists(resolvedEnvFilePath))
170+
? resolvedEnvFilePath
171+
: (await fse.pathExists(defaultEnvFilePath))
172+
? defaultEnvFilePath
173+
: undefined;
174+
if (activeEnvFilePath) {
175175
traceVerbose(`TerminalEnvVarInjector: Using env file: ${activeEnvFilePath}`);
176176
} else {
177177
traceVerbose(
@@ -265,18 +265,6 @@ export class TerminalEnvVarInjector implements Disposable {
265265
return envVarCollection.getScoped(scope);
266266
}
267267

268-
/**
269-
* Clear all environment variables for a workspace.
270-
*/
271-
private clearWorkspaceVariables(workspaceFolder: WorkspaceFolder): void {
272-
try {
273-
const scope = this.getEnvironmentVariableCollectionScoped({ workspaceFolder });
274-
scope.clear();
275-
} catch (error) {
276-
traceError(`Failed to clear environment variables for workspace ${workspaceFolder.uri.fsPath}:`, error);
277-
}
278-
}
279-
280268
/**
281269
* Clear only the .env variables we've tracked, not shell activation variables.
282270
*/

‎src/test/features/terminalEnvVarInjector.unit.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import * as assert from 'assert';
5+
import fse from 'fs-extra';
56
import * as path from 'path';
67
import * as sinon from 'sinon';
78
import * as typeMoq from 'typemoq';
@@ -151,6 +152,107 @@ suite('TerminalEnvVarInjector', () => {
151152
assert.strictEqual(mockScopedCollection.replace.called, false);
152153
});
153154

155+
suite('env file changes', () => {
156+
let envChangeCallback: ((args: { uri?: Uri; changeType: number }) => void) | undefined;
157+
let variables: Record<string, string>;
158+
let existingFiles: Set<string>;
159+
const defaultEnvFile = path.join(testWorkspaceFolder.uri.fsPath, '.env');
160+
const configuredEnvFile = path.join(testWorkspaceFolder.uri.fsPath, 'configured.env');
161+
162+
setup(() => {
163+
variables = {};
164+
existingFiles = new Set();
165+
sinon.stub(fse, 'pathExists').callsFake(async (filePath) => existingFiles.has(path.resolve(filePath.toString())));
166+
sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(testWorkspaceFolder);
167+
envVarManager.reset();
168+
envVarManager.setup((m) => m.onDidChangeEnvironmentVariables).returns(
169+
() => (listener) => {
170+
envChangeCallback = listener;
171+
return new Disposable(() => {});
172+
},
173+
);
174+
envVarManager
175+
.setup((m) => m.getEnvironmentVariables(typeMoq.It.isAny()))
176+
.returns(() => Promise.resolve({ ...variables }));
177+
});
178+
179+
async function fireChange(changeType: number, filePath = defaultEnvFile): Promise<void> {
180+
assert.ok(envChangeCallback);
181+
envChangeCallback({ uri: Uri.file(filePath), changeType });
182+
await new Promise((resolve) => setTimeout(resolve, 20));
183+
}
184+
185+
test('creating an env file with injection disabled preserves shell activation variables', async () => {
186+
getConfigurationStub.returns(createMockConfig({ useEnvFile: false }) as WorkspaceConfiguration);
187+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
188+
await fireChange(2);
189+
190+
sinon.assert.notCalled(mockScopedCollection.clear);
191+
sinon.assert.notCalled(mockScopedCollection.delete);
192+
sinon.assert.notCalled(mockScopedCollection.replace);
193+
});
194+
195+
test('creating, editing, and deleting an env file updates only its injected variables', async () => {
196+
getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration);
197+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
198+
await new Promise((resolve) => setTimeout(resolve, 20));
199+
200+
existingFiles.add(defaultEnvFile);
201+
variables = { TERMINAL_PROBE_VALUE: 'created' };
202+
await fireChange(2);
203+
sinon.assert.calledWith(mockScopedCollection.replace, 'TERMINAL_PROBE_VALUE', 'created');
204+
205+
variables = { OTHER_VALUE: 'edited' };
206+
await fireChange(1);
207+
sinon.assert.calledWith(mockScopedCollection.delete, 'TERMINAL_PROBE_VALUE');
208+
sinon.assert.calledWith(mockScopedCollection.replace, 'OTHER_VALUE', 'edited');
209+
210+
existingFiles.delete(defaultEnvFile);
211+
variables = {};
212+
await fireChange(3);
213+
sinon.assert.calledWith(mockScopedCollection.delete, 'OTHER_VALUE');
214+
sinon.assert.notCalled(mockScopedCollection.clear);
215+
});
216+
217+
test('deleting one env file retains variables from the other configured file', async () => {
218+
getConfigurationStub.returns(
219+
createMockConfig({ useEnvFile: true, envFilePath: configuredEnvFile }) as WorkspaceConfiguration,
220+
);
221+
existingFiles.add(defaultEnvFile);
222+
existingFiles.add(configuredEnvFile);
223+
variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' };
224+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
225+
await new Promise((resolve) => setTimeout(resolve, 20));
226+
227+
existingFiles.delete(configuredEnvFile);
228+
variables = { PROJECT_VALUE: 'project' };
229+
await fireChange(3, configuredEnvFile);
230+
231+
sinon.assert.calledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE');
232+
sinon.assert.neverCalledWith(mockScopedCollection.delete, 'PROJECT_VALUE');
233+
sinon.assert.notCalled(mockScopedCollection.clear);
234+
});
235+
236+
test('deleting the project env file retains variables from the configured file', async () => {
237+
getConfigurationStub.returns(
238+
createMockConfig({ useEnvFile: true, envFilePath: configuredEnvFile }) as WorkspaceConfiguration,
239+
);
240+
existingFiles.add(defaultEnvFile);
241+
existingFiles.add(configuredEnvFile);
242+
variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' };
243+
injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object);
244+
await new Promise((resolve) => setTimeout(resolve, 20));
245+
246+
existingFiles.delete(defaultEnvFile);
247+
variables = { CONFIGURED_VALUE: 'configured' };
248+
await fireChange(3);
249+
250+
sinon.assert.calledWith(mockScopedCollection.delete, 'PROJECT_VALUE');
251+
sinon.assert.neverCalledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE');
252+
sinon.assert.notCalled(mockScopedCollection.clear);
253+
});
254+
});
255+
154256
test('should NOT inject when useEnvFile is false even with python.envFile configured', async () => {
155257
getConfigurationStub.returns(
156258
createMockConfig({

0 commit comments

Comments
 (0)