Skip to content

Commit 6e5b27b

Browse files
authored
feat: add command to reveal environment in manager view (#1186)
fixes #1187
1 parent 7cea36b commit 6e5b27b

File tree

9 files changed

+380
-29
lines changed

9 files changed

+380
-29
lines changed

.github/instructions/testing-workflow.instructions.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,20 @@ This guide covers the full testing lifecycle:
3737

3838
These mistakes have occurred REPEATEDLY. Check this list BEFORE writing any test code:
3939

40-
| Mistake | Fix |
41-
| ---------------------------------------------- | ------------------------------------------------------------------ |
42-
| Hardcoded POSIX paths like `'/test/workspace'` | Use `'.'` for relative paths, `Uri.file(x).fsPath` for comparisons |
43-
| Stubbing `workspace.getConfiguration` directly | Stub the wrapper `workspaceApis.getConfiguration` instead |
44-
| Stubbing `workspace.workspaceFolders` property | Stub wrapper function `workspaceApis.getWorkspaceFolders()` |
45-
| Comparing `fsPath` to raw string | Compare `fsPath` to `Uri.file(expected).fsPath` |
40+
| Mistake | Fix |
41+
| ---------------------------------------------- | ------------------------------------------------------------------------------------------ |
42+
| Hardcoded POSIX paths like `'/test/workspace'` | Use `'.'` for relative paths, `Uri.file(x).fsPath` for comparisons |
43+
| Stubbing `workspace.getConfiguration` directly | Stub the wrapper `workspaceApis.getConfiguration` instead |
44+
| Stubbing `workspace.workspaceFolders` property | Stub wrapper function `workspaceApis.getWorkspaceFolders()` |
45+
| Comparing `fsPath` to raw string | Compare `fsPath` to `Uri.file(expected).fsPath` |
46+
| Stubbing `commands.executeCommand` directly | First update production code to use `executeCommand` from `command.api.ts`, then stub that |
47+
| Stubbing `window.createTreeView` directly | First update production code to use `createTreeView` from `window.apis.ts`, then stub that |
4648

4749
**Pre-flight checklist before completing test work:**
4850

4951
- [ ] All paths use `Uri.file().fsPath` (no hardcoded `/path/to/x`)
5052
- [ ] All VS Code API stubs use wrapper modules, not `vscode.*` directly
53+
- [ ] Production code uses wrappers for any VS Code API that tests need to stub (check `src/common/*.apis.ts`)
5154
- [ ] Tests pass on both Windows and POSIX
5255

5356
## Test Types
@@ -597,4 +600,5 @@ envConfig.inspect
597600
- Use `sinon.useFakeTimers()` with `clock.tickAsync()` instead of `await new Promise(resolve => setTimeout(resolve, ms))` for debounce/timeout handling - eliminates flakiness and speeds up tests significantly (1)
598601
- Always compile tests (`npm run compile-tests`) before running them after adding new test cases - test counts will be wrong if running against stale compiled output (1)
599602
- Never create "documentation tests" that just `assert.ok(true)` — if mocking limitations prevent testing, either test a different layer that IS mockable, or skip the test entirely with a clear explanation (1)
600-
- When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (3)
603+
- When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (4)
604+
- **Before writing tests**, check if the function under test calls VS Code APIs directly (e.g., `commands.executeCommand`, `window.createTreeView`, `workspace.getConfiguration`). If so, FIRST update the production code to use wrapper functions from `src/common/*.apis.ts` (create the wrapper if it doesn't exist), THEN write tests that stub those wrappers. This prevents CI failures where sinon cannot stub the vscode namespace (4)

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,12 @@
314314
"category": "Python Envs",
315315
"icon": "$(folder-opened)"
316316
},
317+
{
318+
"command": "python-envs.revealEnvInManagerView",
319+
"title": "%python-envs.revealEnvInManagerView.title%",
320+
"category": "Python Envs",
321+
"icon": "$(eye)"
322+
},
317323
{
318324
"command": "python-envs.runPetInTerminal",
319325
"title": "%python-envs.runPetInTerminal.title%",
@@ -424,6 +430,10 @@
424430
"command": "python-envs.revealProjectInExplorer",
425431
"when": "false"
426432
},
433+
{
434+
"command": "python-envs.revealEnvInManagerView",
435+
"when": "false"
436+
},
427437
{
428438
"command": "python-envs.createNewProjectFromTemplate",
429439
"when": "config.python.useEnvironmentsExtension != false"
@@ -532,6 +542,11 @@
532542
"command": "python-envs.uninstallPackage",
533543
"group": "inline",
534544
"when": "view == python-projects && viewItem == python-package"
545+
},
546+
{
547+
"command": "python-envs.revealEnvInManagerView",
548+
"group": "inline",
549+
"when": "view == python-projects && viewItem == python-env"
535550
}
536551
],
537552
"view/title": [

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal",
4343
"python-envs.uninstallPackage.title": "Uninstall Package",
4444
"python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer",
45+
"python-envs.revealEnvInManagerView.title": "Reveal in Environment Managers View",
4546
"python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...",
4647
"python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv."
4748
}

src/common/window.apis.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
TerminalShellExecutionStartEvent,
2828
TerminalShellIntegrationChangeEvent,
2929
TextEditor,
30+
TreeView,
31+
TreeViewOptions,
3032
Uri,
3133
window,
3234
WindowState,
@@ -377,3 +379,7 @@ export function onDidChangeWindowState(
377379
): Disposable {
378380
return window.onDidChangeWindowState(listener, thisArgs, disposables);
379381
}
382+
383+
export function createTreeView<T>(viewId: string, options: TreeViewOptions<T>): TreeView<T> {
384+
return window.createTreeView(viewId, options);
385+
}

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
refreshPackagesCommand,
3636
removeEnvironmentCommand,
3737
removePythonProject,
38+
revealEnvInManagerView,
3839
revealProjectInExplorer,
3940
runAsTaskCommand,
4041
runInDedicatedTerminalCommand,
@@ -313,6 +314,9 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
313314
commands.registerCommand('python-envs.revealProjectInExplorer', async (item) => {
314315
await revealProjectInExplorer(item);
315316
}),
317+
commands.registerCommand('python-envs.revealEnvInManagerView', async (item) => {
318+
await revealEnvInManagerView(item, managerView);
319+
}),
316320
commands.registerCommand('python-envs.terminal.activate', async () => {
317321
const terminal = activeTerminal();
318322
if (terminal) {

src/features/envCommands.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import * as fs from 'fs-extra';
22
import * as path from 'path';
3-
import {
4-
commands,
5-
ProgressLocation,
6-
QuickInputButtons,
7-
TaskExecution,
8-
TaskRevealKind,
9-
Terminal,
10-
Uri,
11-
workspace,
12-
} from 'vscode';
3+
import { ProgressLocation, QuickInputButtons, TaskExecution, TaskRevealKind, Terminal, Uri, workspace } from 'vscode';
134
import {
145
CreateEnvironmentOptions,
156
PythonEnvironment,
@@ -28,6 +19,7 @@ import {
2819
} from '../internal.api';
2920
import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers';
3021

22+
import { executeCommand } from '../common/command.api';
3123
import { clipboardWriteText } from '../common/env.apis';
3224
import {} from '../common/errors/utils';
3325
import { Pickers } from '../common/localize';
@@ -52,6 +44,7 @@ import {
5244
import { runAsTask } from './execution/runAsTask';
5345
import { runInTerminal } from './terminal/runInTerminal';
5446
import { TerminalManager } from './terminal/terminalManager';
47+
import { EnvManagerView } from './views/envManagersView';
5548
import {
5649
EnvManagerTreeItem,
5750
EnvTreeItemKind,
@@ -469,7 +462,7 @@ export async function addPythonProjectCommand(
469462
'Open Folder',
470463
);
471464
if (r === 'Open Folder') {
472-
await commands.executeCommand('vscode.openFolder');
465+
await executeCommand('vscode.openFolder');
473466
return;
474467
}
475468
}
@@ -746,8 +739,21 @@ export async function copyPathToClipboard(item: unknown): Promise<void> {
746739
export async function revealProjectInExplorer(item: unknown): Promise<void> {
747740
if (item instanceof ProjectItem) {
748741
const projectUri = item.project.uri;
749-
await commands.executeCommand('revealInExplorer', projectUri);
742+
await executeCommand('revealInExplorer', projectUri);
750743
} else {
751744
traceVerbose(`Invalid context for reveal project in explorer: ${item}`);
752745
}
753746
}
747+
748+
/**
749+
* Focuses the Environment Managers view and reveals the given project environment.
750+
*/
751+
export async function revealEnvInManagerView(item: unknown, managerView: EnvManagerView): Promise<void> {
752+
if (item instanceof ProjectEnvironment) {
753+
await executeCommand('env-managers.focus');
754+
await managerView.reveal(item.environment);
755+
return;
756+
}
757+
758+
traceVerbose(`Invalid context for reveal environment in manager view: ${item}`);
759+
}

src/features/views/envManagersView.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView, window } from 'vscode';
1+
import { Disposable, Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem, TreeView } from 'vscode';
22
import { DidChangeEnvironmentEventArgs, EnvironmentGroupInfo, PythonEnvironment } from '../../api';
33
import { ProjectViews } from '../../common/localize';
44
import { createSimpleDebounce } from '../../common/utils/debounce';
5+
import { createTreeView } from '../../common/window.apis';
56
import {
67
DidChangeEnvironmentManagerEventArgs,
78
DidChangePackageManagerEventArgs,
@@ -34,18 +35,23 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
3435
>();
3536
private revealMap = new Map<string, PythonEnvTreeItem>();
3637
private managerViews = new Map<string, EnvManagerTreeItem>();
38+
private groupViews = new Map<string, PythonGroupEnvTreeItem>();
3739
private selected: Map<string, string> = new Map();
3840
private disposables: Disposable[] = [];
3941

40-
public constructor(public providers: EnvironmentManagers, private stateManager: ITemporaryStateManager) {
41-
this.treeView = window.createTreeView<EnvTreeItem>('env-managers', {
42+
public constructor(
43+
public providers: EnvironmentManagers,
44+
private stateManager: ITemporaryStateManager,
45+
) {
46+
this.treeView = createTreeView<EnvTreeItem>('env-managers', {
4247
treeDataProvider: this,
4348
});
4449

4550
this.disposables.push(
4651
new Disposable(() => {
4752
this.revealMap.clear();
4853
this.managerViews.clear();
54+
this.groupViews.clear();
4955
this.selected.clear();
5056
}),
5157
this.treeView,
@@ -107,6 +113,7 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
107113
if (!element) {
108114
const views: EnvTreeItem[] = [];
109115
this.managerViews.clear();
116+
this.groupViews.clear();
110117
this.providers.managers.forEach((m) => {
111118
const view = new EnvManagerTreeItem(m);
112119
views.push(view);
@@ -137,7 +144,10 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
137144
});
138145

139146
groupObjects.forEach((group) => {
140-
views.push(new PythonGroupEnvTreeItem(element as EnvManagerTreeItem, group));
147+
const groupView = new PythonGroupEnvTreeItem(element as EnvManagerTreeItem, group);
148+
const groupName = typeof group === 'string' ? group : group.name;
149+
this.groupViews.set(`${manager.id}:${groupName}`, groupView);
150+
views.push(groupView);
141151
});
142152

143153
if (views.length === 0) {
@@ -202,12 +212,47 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
202212
return element.parent;
203213
}
204214

205-
reveal(environment?: PythonEnvironment) {
206-
const view = environment ? this.revealMap.get(environment.envId.id) : undefined;
215+
/**
216+
* Reveals and focuses on the given environment in the Environment Managers view.
217+
*
218+
* @param environment - The Python environment to reveal
219+
*/
220+
async reveal(environment?: PythonEnvironment): Promise<void> {
221+
if (!environment) {
222+
return;
223+
}
224+
225+
const manager = this.providers.getEnvironmentManager(environment);
226+
if (!manager) {
227+
return;
228+
}
229+
230+
if (!this.managerViews.has(manager.id)) {
231+
await this.getChildren(undefined);
232+
}
233+
234+
const managerView = this.managerViews.get(manager.id);
235+
if (!managerView) {
236+
return;
237+
}
238+
239+
const groupName = typeof environment.group === 'string' ? environment.group : environment.group?.name;
240+
if (groupName) {
241+
if (!this.groupViews.has(`${manager.id}:${groupName}`)) {
242+
await this.getChildren(managerView);
243+
}
244+
245+
const groupView = this.groupViews.get(`${manager.id}:${groupName}`);
246+
if (groupView) {
247+
await this.getChildren(groupView);
248+
}
249+
} else {
250+
await this.getChildren(managerView);
251+
}
252+
253+
const view = this.revealMap.get(environment.envId.id);
207254
if (view && this.treeView.visible) {
208-
setImmediate(async () => {
209-
await this.treeView.reveal(view);
210-
});
255+
await this.treeView.reveal(view, { expand: false, focus: true, select: true });
211256
}
212257
}
213258

src/test/features/envCommands.unit.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import * as sinon from 'sinon';
33
import * as typeMoq from 'typemoq';
44
import { Uri } from 'vscode';
55
import { PythonEnvironment, PythonProject } from '../../api';
6+
import * as commandApi from '../../common/command.api';
67
import * as managerApi from '../../common/pickers/managers';
78
import * as projectApi from '../../common/pickers/projects';
8-
import { createAnyEnvironmentCommand } from '../../features/envCommands';
9+
import { createAnyEnvironmentCommand, revealEnvInManagerView } from '../../features/envCommands';
10+
import { EnvManagerView } from '../../features/views/envManagersView';
11+
import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems';
912
import { EnvironmentManagers, InternalEnvironmentManager, PythonProjectManager } from '../../internal.api';
1013
import { setupNonThenable } from '../mocks/helper';
1114

@@ -175,3 +178,49 @@ suite('Create Any Environment Command Tests', () => {
175178
em.verifyAll();
176179
});
177180
});
181+
182+
suite('Reveal Env In Manager View Command Tests', () => {
183+
let managerView: typeMoq.IMock<EnvManagerView>;
184+
let executeCommandStub: sinon.SinonStub;
185+
186+
setup(() => {
187+
managerView = typeMoq.Mock.ofType<EnvManagerView>();
188+
setupNonThenable(managerView);
189+
executeCommandStub = sinon.stub(commandApi, 'executeCommand');
190+
});
191+
192+
teardown(() => {
193+
sinon.restore();
194+
});
195+
196+
test('Focuses env-managers view and reveals environment when given a ProjectEnvironment', async () => {
197+
// Mock
198+
const project: PythonProject = {
199+
uri: Uri.file('/test/project'),
200+
name: 'test-project',
201+
};
202+
const projectItem = new ProjectItem(project);
203+
204+
const environment: PythonEnvironment = {
205+
envId: { id: 'test-env-id', managerId: 'test-manager' },
206+
name: 'test-env',
207+
displayName: 'Test Environment',
208+
displayPath: '/path/to/env',
209+
version: '3.10.0',
210+
environmentPath: Uri.file('/path/to/env'),
211+
execInfo: { run: { executable: '/path/to/python' }, activatedRun: { executable: '/path/to/python' } },
212+
sysPrefix: '/path/to/env',
213+
};
214+
const projectEnv = new ProjectEnvironment(projectItem, environment);
215+
216+
executeCommandStub.resolves();
217+
managerView.setup((m) => m.reveal(environment)).returns(() => Promise.resolve());
218+
219+
// Run
220+
await revealEnvInManagerView(projectEnv, managerView.object);
221+
222+
// Assert
223+
assert.ok(executeCommandStub.calledOnceWith('env-managers.focus'), 'Should focus the env-managers view');
224+
managerView.verify((m) => m.reveal(environment), typeMoq.Times.once());
225+
});
226+
});

0 commit comments

Comments
 (0)