Skip to content

Commit eefe310

Browse files
committed
merging
2 parents 36a789c + 451851c commit eefe310

File tree

14 files changed

+472
-45
lines changed

14 files changed

+472
-45
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: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"publisher": "ms-python",
77
"preview": true,
88
"engines": {
9-
"vscode": "^1.110.0-20260204"
9+
"vscode": "^1.110.0-20260204"
1010
},
1111
"categories": [
1212
"Other"
@@ -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"
@@ -447,6 +457,10 @@
447457
"command": "python-envs.remove",
448458
"when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*;remove;.*/"
449459
},
460+
{
461+
"command": "python-envs.remove",
462+
"when": "view == env-managers && viewItem =~ /.*pythonBrokenEnvironment.*;remove;.*/"
463+
},
450464
{
451465
"command": "python-envs.setEnv",
452466
"group": "inline",
@@ -481,6 +495,16 @@
481495
"group": "inline",
482496
"when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/ && viewItem =~ /.*copied.*/"
483497
},
498+
{
499+
"command": "python-envs.copyEnvPath",
500+
"group": "inline",
501+
"when": "view == env-managers && viewItem =~ /.*pythonBrokenEnvironment.*/ && viewItem =~ /^((?!copied).)*$/"
502+
},
503+
{
504+
"command": "python-envs.copyEnvPathCopied",
505+
"group": "inline",
506+
"when": "view == env-managers && viewItem =~ /.*pythonBrokenEnvironment.*/ && viewItem =~ /.*copied.*/"
507+
},
484508
{
485509
"command": "python-envs.uninstallPackage",
486510
"group": "inline",
@@ -532,6 +556,11 @@
532556
"command": "python-envs.uninstallPackage",
533557
"group": "inline",
534558
"when": "view == python-projects && viewItem == python-package"
559+
},
560+
{
561+
"command": "python-envs.revealEnvInManagerView",
562+
"group": "inline",
563+
"when": "view == python-projects && viewItem == python-env"
535564
}
536565
],
537566
"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/api.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,13 @@ export interface PythonEnvironmentInfo {
217217
* Optional `group` for this environment. This is used to group environments in the Environment Manager UI.
218218
*/
219219
readonly group?: string | EnvironmentGroupInfo;
220+
221+
/**
222+
* Error message if the environment is broken or invalid.
223+
* When set, indicates this environment has issues (e.g., broken symlinks, missing Python executable).
224+
* The UI should display a warning indicator and show this message to help users diagnose and fix the issue.
225+
*/
226+
readonly error?: string;
220227
}
221228

222229
/**
@@ -922,7 +929,8 @@ export interface PythonProjectEnvironmentApi {
922929
}
923930

924931
export interface PythonEnvironmentManagerApi
925-
extends PythonEnvironmentManagerRegistrationApi,
932+
extends
933+
PythonEnvironmentManagerRegistrationApi,
926934
PythonEnvironmentItemApi,
927935
PythonEnvironmentManagementApi,
928936
PythonEnvironmentsApi,
@@ -987,7 +995,8 @@ export interface PythonPackageManagementApi {
987995
}
988996

989997
export interface PythonPackageManagerApi
990-
extends PythonPackageManagerRegistrationApi,
998+
extends
999+
PythonPackageManagerRegistrationApi,
9911000
PythonPackageGetterApi,
9921001
PythonPackageManagementApi,
9931002
PythonPackageItemApi {}
@@ -1206,10 +1215,7 @@ export interface PythonBackgroundRunApi {
12061215
}
12071216

12081217
export interface PythonExecutionApi
1209-
extends PythonTerminalCreateApi,
1210-
PythonTerminalRunApi,
1211-
PythonTaskRunApi,
1212-
PythonBackgroundRunApi {}
1218+
extends PythonTerminalCreateApi, PythonTerminalRunApi, PythonTaskRunApi, PythonBackgroundRunApi {}
12131219

12141220
/**
12151221
* Event arguments for when the monitored `.env` files or any other sources change.
@@ -1258,7 +1264,8 @@ export interface PythonEnvironmentVariablesApi {
12581264
* The API for interacting with Python environments, package managers, and projects.
12591265
*/
12601266
export interface PythonEnvironmentApi
1261-
extends PythonEnvironmentManagerApi,
1267+
extends
1268+
PythonEnvironmentManagerApi,
12621269
PythonPackageManagerApi,
12631270
PythonProjectApi,
12641271
PythonExecutionApi,

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: 7 additions & 1 deletion
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,
@@ -177,7 +178,9 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
177178
}),
178179
commands.registerCommand('python-envs.viewLogs', () => outputChannel.show()),
179180
commands.registerCommand('python-envs.refreshAllManagers', async () => {
180-
await Promise.all(envManagers.managers.map((m) => m.refresh(undefined)));
181+
await window.withProgress({ location: { viewId: 'env-managers' } }, async () => {
182+
await Promise.all(envManagers.managers.map((m) => m.refresh(undefined)));
183+
});
181184
}),
182185
commands.registerCommand('python-envs.refreshPackages', async (item) => {
183186
await refreshPackagesCommand(item, envManagers);
@@ -313,6 +316,9 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
313316
commands.registerCommand('python-envs.revealProjectInExplorer', async (item) => {
314317
await revealProjectInExplorer(item);
315318
}),
319+
commands.registerCommand('python-envs.revealEnvInManagerView', async (item) => {
320+
await revealEnvInManagerView(item, managerView);
321+
}),
316322
commands.registerCommand('python-envs.terminal.activate', async () => {
317323
const terminal = activeTerminal();
318324
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: 50 additions & 8 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,
@@ -82,21 +83,23 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
8283
>();
8384
private revealMap = new Map<string, PythonEnvTreeItem>();
8485
private managerViews = new Map<string, EnvManagerTreeItem>();
86+
private groupViews = new Map<string, PythonGroupEnvTreeItem>();
8587
private selected: Map<string, string> = new Map();
8688
private disposables: Disposable[] = [];
8789

8890
public constructor(
8991
public providers: EnvironmentManagers,
9092
private stateManager: ITemporaryStateManager,
9193
) {
92-
this.treeView = window.createTreeView<EnvTreeItem>('env-managers', {
94+
this.treeView = createTreeView<EnvTreeItem>('env-managers', {
9395
treeDataProvider: this,
9496
});
9597

9698
this.disposables.push(
9799
new Disposable(() => {
98100
this.revealMap.clear();
99101
this.managerViews.clear();
102+
this.groupViews.clear();
100103
this.selected.clear();
101104
}),
102105
this.treeView,
@@ -158,6 +161,7 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
158161
if (!element) {
159162
const views: EnvTreeItem[] = [];
160163
this.managerViews.clear();
164+
this.groupViews.clear();
161165
this.providers.managers.forEach((m) => {
162166
const view = new EnvManagerTreeItem(m);
163167
views.push(view);
@@ -196,7 +200,10 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
196200
});
197201

198202
groupObjects.forEach((group) => {
199-
views.push(new PythonGroupEnvTreeItem(element as EnvManagerTreeItem, group));
203+
const groupView = new PythonGroupEnvTreeItem(element as EnvManagerTreeItem, group);
204+
const groupName = typeof group === 'string' ? group : group.name;
205+
this.groupViews.set(`${manager.id}:${groupName}`, groupView);
206+
views.push(groupView);
200207
});
201208

202209
if (views.length === 0) {
@@ -263,12 +270,47 @@ export class EnvManagerView implements TreeDataProvider<EnvTreeItem>, Disposable
263270
return element.parent;
264271
}
265272

266-
reveal(environment?: PythonEnvironment) {
267-
const view = environment ? this.revealMap.get(environment.envId.id) : undefined;
273+
/**
274+
* Reveals and focuses on the given environment in the Environment Managers view.
275+
*
276+
* @param environment - The Python environment to reveal
277+
*/
278+
async reveal(environment?: PythonEnvironment): Promise<void> {
279+
if (!environment) {
280+
return;
281+
}
282+
283+
const manager = this.providers.getEnvironmentManager(environment);
284+
if (!manager) {
285+
return;
286+
}
287+
288+
if (!this.managerViews.has(manager.id)) {
289+
await this.getChildren(undefined);
290+
}
291+
292+
const managerView = this.managerViews.get(manager.id);
293+
if (!managerView) {
294+
return;
295+
}
296+
297+
const groupName = typeof environment.group === 'string' ? environment.group : environment.group?.name;
298+
if (groupName) {
299+
if (!this.groupViews.has(`${manager.id}:${groupName}`)) {
300+
await this.getChildren(managerView);
301+
}
302+
303+
const groupView = this.groupViews.get(`${manager.id}:${groupName}`);
304+
if (groupView) {
305+
await this.getChildren(groupView);
306+
}
307+
} else {
308+
await this.getChildren(managerView);
309+
}
310+
311+
const view = this.revealMap.get(environment.envId.id);
268312
if (view && this.treeView.visible) {
269-
setImmediate(async () => {
270-
await this.treeView.reveal(view);
271-
});
313+
await this.treeView.reveal(view, { expand: false, focus: true, select: true });
272314
}
273315
}
274316

0 commit comments

Comments
 (0)