Skip to content

Commit 35eac06

Browse files
Copiloteleanorjboyd
andcommitted
feat: add "Current File" options to project picker in status bar (#222)
When the user clicks the status bar and a Python file is active, inject two special items at the top of the project picker: - "Set for current file" — scopes environment to just the active file URI - "Add current file as project..." — creates a project at the file's parent directory, then opens the environment picker for it This restores the per-file environment selection that was lost when projects were introduced. Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Agent-Logs-Url: https://github.com/microsoft/vscode-python-environments/sessions/79de8f9f-18f1-483a-b8e3-146947202da5
1 parent da92e8f commit 35eac06

5 files changed

Lines changed: 501 additions & 7 deletions

File tree

src/common/localize.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ export namespace Pickers {
6565
export namespace Project {
6666
export const selectProject = l10n.t('Select a project, folder or script');
6767
export const selectProjects = l10n.t('Select one or more projects, folders or scripts');
68+
export const setForCurrentFile = l10n.t('Set for current file');
69+
export const addCurrentFileAsProject = l10n.t('Add current file as project...');
70+
export const currentFileSection = l10n.t('Current File');
71+
export const projectsSection = l10n.t('Projects');
6872
}
6973

7074
export namespace pyProject {

src/common/pickers/projects.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from 'path';
2-
import { QuickPickItem } from 'vscode';
2+
import { QuickPickItem, QuickPickItemKind, Uri } from 'vscode';
33
import { PythonProject } from '../../api';
44
import { showQuickPick, showQuickPickWithButtons } from '../window.apis';
55
import { Pickers } from '../localize';
@@ -8,6 +8,33 @@ interface ProjectQuickPickItem extends QuickPickItem {
88
project: PythonProject;
99
}
1010

11+
export const CURRENT_FILE_ACTION = 'currentFile';
12+
export const ADD_PROJECT_ACTION = 'addProject';
13+
14+
export interface CurrentFileResult {
15+
action: typeof CURRENT_FILE_ACTION;
16+
fileUri: Uri;
17+
}
18+
19+
export interface AddProjectResult {
20+
action: typeof ADD_PROJECT_ACTION;
21+
fileUri: Uri;
22+
}
23+
24+
export interface ProjectsResult {
25+
action: 'projects';
26+
projects: PythonProject[];
27+
}
28+
29+
export type ProjectPickerResult = CurrentFileResult | AddProjectResult | ProjectsResult | undefined;
30+
31+
interface ActionQuickPickItem extends QuickPickItem {
32+
action: typeof CURRENT_FILE_ACTION | typeof ADD_PROJECT_ACTION;
33+
fileUri: Uri;
34+
}
35+
36+
type EnrichedQuickPickItem = ProjectQuickPickItem | ActionQuickPickItem | QuickPickItem;
37+
1138
export async function pickProject(projects: ReadonlyArray<PythonProject>): Promise<PythonProject | undefined> {
1239
if (projects.length > 1) {
1340
const items: ProjectQuickPickItem[] = projects.map((pw) => ({
@@ -54,3 +81,72 @@ export async function pickProjectMany(
5481
}
5582
return undefined;
5683
}
84+
85+
/**
86+
* Shows a project picker with additional "Current File" options at the top.
87+
* When the active editor has a Python file, two special items are injected:
88+
* - "Set for current file" — scopes environment to just the active file URI
89+
* - "Add current file as project..." — creates a project at the file's parent directory
90+
*
91+
* @param projects - The list of existing projects to show
92+
* @param activeFileUri - The URI of the active Python file (if any)
93+
* @returns A discriminated result indicating the user's choice, or undefined if cancelled
94+
*/
95+
export async function pickProjectWithCurrentFile(
96+
projects: readonly PythonProject[],
97+
activeFileUri: Uri,
98+
): Promise<ProjectPickerResult> {
99+
const items: EnrichedQuickPickItem[] = [];
100+
101+
// Current file section
102+
items.push({
103+
label: Pickers.Project.currentFileSection,
104+
kind: QuickPickItemKind.Separator,
105+
});
106+
items.push({
107+
label: `$(file) ${Pickers.Project.setForCurrentFile}`,
108+
description: path.basename(activeFileUri.fsPath),
109+
action: CURRENT_FILE_ACTION,
110+
fileUri: activeFileUri,
111+
} as ActionQuickPickItem);
112+
items.push({
113+
label: `$(add) ${Pickers.Project.addCurrentFileAsProject}`,
114+
description: path.dirname(activeFileUri.fsPath),
115+
action: ADD_PROJECT_ACTION,
116+
fileUri: activeFileUri,
117+
} as ActionQuickPickItem);
118+
119+
// Projects section
120+
items.push({
121+
label: Pickers.Project.projectsSection,
122+
kind: QuickPickItemKind.Separator,
123+
});
124+
for (const pw of projects) {
125+
items.push({
126+
label: path.basename(pw.uri.fsPath),
127+
description: pw.uri.fsPath,
128+
project: pw,
129+
} as ProjectQuickPickItem);
130+
}
131+
132+
const selected = await showQuickPickWithButtons(items, {
133+
placeHolder: Pickers.Project.selectProjects,
134+
ignoreFocusOut: true,
135+
});
136+
137+
if (!selected) {
138+
return undefined;
139+
}
140+
141+
if ('action' in selected) {
142+
const actionItem = selected as ActionQuickPickItem;
143+
return { action: actionItem.action, fileUri: actionItem.fileUri };
144+
}
145+
146+
if ('project' in selected) {
147+
const projectItem = selected as ProjectQuickPickItem;
148+
return { action: 'projects', projects: [projectItem.project] };
149+
}
150+
151+
return undefined;
152+
}

src/features/envCommands.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
pickPackageManager,
4141
pickWorkspaceFolder,
4242
} from '../common/pickers/managers';
43-
import { pickProject, pickProjectMany } from '../common/pickers/projects';
43+
import { pickProject, pickProjectMany, pickProjectWithCurrentFile, ADD_PROJECT_ACTION, CURRENT_FILE_ACTION } from '../common/pickers/projects';
4444
import { isWindows } from '../common/utils/platformUtils';
4545
import { handlePythonPath } from '../common/utils/pythonPath';
4646
import {
@@ -350,10 +350,41 @@ export async function setEnvironmentCommand(
350350
try {
351351
const projects = wm.getProjects();
352352
if (projects.length > 0) {
353-
const selected = await pickProjectMany(projects);
354-
if (selected && selected.length > 0) {
355-
const uris = selected.map((p) => p.uri);
356-
await setEnvironmentCommand(uris, em, wm);
353+
// Check if the active editor has a Python file open
354+
const activeEditor = activeTextEditor();
355+
const activeFileUri =
356+
activeEditor?.document?.languageId === 'python' &&
357+
activeEditor.document.uri.scheme === 'file' &&
358+
!activeEditor.document.isUntitled
359+
? activeEditor.document.uri
360+
: undefined;
361+
362+
if (activeFileUri) {
363+
// Show enriched picker with current file options
364+
const result = await pickProjectWithCurrentFile(projects, activeFileUri);
365+
if (result) {
366+
if (result.action === CURRENT_FILE_ACTION) {
367+
await setEnvironmentCommand([result.fileUri], em, wm);
368+
} else if (result.action === ADD_PROJECT_ACTION) {
369+
const parentUri = Uri.file(path.dirname(result.fileUri.fsPath));
370+
await executeCommand('python-envs.addPythonProjectGivenResource', parentUri);
371+
// Find the newly created project and open environment picker
372+
const newProject = wm.get(parentUri);
373+
if (newProject) {
374+
await setEnvironmentCommand([newProject.uri], em, wm);
375+
}
376+
} else {
377+
const uris = result.projects.map((p) => p.uri);
378+
await setEnvironmentCommand(uris, em, wm);
379+
}
380+
}
381+
} else {
382+
// No active Python file; use standard multi-select project picker
383+
const selected = await pickProjectMany(projects);
384+
if (selected && selected.length > 0) {
385+
const uris = selected.map((p) => p.uri);
386+
await setEnvironmentCommand(uris, em, wm);
387+
}
357388
}
358389
} else {
359390
const globalEnvManager = em.getEnvironmentManager(undefined);
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { QuickPickItemKind, Uri } from 'vscode';
4+
import { PythonProject } from '../../../api';
5+
import { Pickers } from '../../../common/localize';
6+
import {
7+
ADD_PROJECT_ACTION,
8+
CURRENT_FILE_ACTION,
9+
pickProjectWithCurrentFile,
10+
} from '../../../common/pickers/projects';
11+
import * as windowApis from '../../../common/window.apis';
12+
13+
suite('pickProjectWithCurrentFile', () => {
14+
let showQuickPickWithButtonsStub: sinon.SinonStub;
15+
16+
const project1: PythonProject = {
17+
uri: Uri.file('/workspace/project1'),
18+
name: 'project1',
19+
};
20+
const project2: PythonProject = {
21+
uri: Uri.file('/workspace/project2'),
22+
name: 'project2',
23+
};
24+
const activeFileUri = Uri.file('/workspace/project1/main.py');
25+
26+
setup(() => {
27+
showQuickPickWithButtonsStub = sinon.stub(windowApis, 'showQuickPickWithButtons');
28+
});
29+
30+
teardown(() => {
31+
sinon.restore();
32+
});
33+
34+
test('should show current file items and project items', async () => {
35+
showQuickPickWithButtonsStub.resolves(undefined);
36+
37+
await pickProjectWithCurrentFile([project1, project2], activeFileUri);
38+
39+
assert.ok(showQuickPickWithButtonsStub.calledOnce, 'showQuickPickWithButtons should be called once');
40+
const items = showQuickPickWithButtonsStub.firstCall.args[0];
41+
42+
// Should have: separator + 2 action items + separator + 2 project items = 6 items
43+
assert.strictEqual(items.length, 6, 'Should have 6 items total');
44+
45+
// First item: current file separator
46+
assert.strictEqual(items[0].kind, QuickPickItemKind.Separator);
47+
assert.strictEqual(items[0].label, Pickers.Project.currentFileSection);
48+
49+
// Second item: "Set for current file"
50+
assert.ok(items[1].label.includes(Pickers.Project.setForCurrentFile));
51+
assert.strictEqual(items[1].action, CURRENT_FILE_ACTION);
52+
assert.strictEqual(items[1].fileUri, activeFileUri);
53+
54+
// Third item: "Add current file as project..."
55+
assert.ok(items[2].label.includes(Pickers.Project.addCurrentFileAsProject));
56+
assert.strictEqual(items[2].action, ADD_PROJECT_ACTION);
57+
assert.strictEqual(items[2].fileUri, activeFileUri);
58+
59+
// Fourth item: projects separator
60+
assert.strictEqual(items[3].kind, QuickPickItemKind.Separator);
61+
assert.strictEqual(items[3].label, Pickers.Project.projectsSection);
62+
63+
// Fifth and sixth items: projects
64+
assert.strictEqual(items[4].project, project1);
65+
assert.strictEqual(items[5].project, project2);
66+
});
67+
68+
test('should return currentFile result when "Set for current file" is selected', async () => {
69+
showQuickPickWithButtonsStub.callsFake((items: unknown[]) => {
70+
// Simulate selecting the "Set for current file" item
71+
return Promise.resolve(items[1]);
72+
});
73+
74+
const result = await pickProjectWithCurrentFile([project1], activeFileUri);
75+
76+
assert.ok(result, 'Result should not be undefined');
77+
assert.strictEqual(result!.action, CURRENT_FILE_ACTION);
78+
if (result!.action === CURRENT_FILE_ACTION) {
79+
assert.strictEqual(result!.fileUri, activeFileUri);
80+
}
81+
});
82+
83+
test('should return addProject result when "Add current file as project..." is selected', async () => {
84+
showQuickPickWithButtonsStub.callsFake((items: unknown[]) => {
85+
// Simulate selecting the "Add current file as project..." item
86+
return Promise.resolve(items[2]);
87+
});
88+
89+
const result = await pickProjectWithCurrentFile([project1], activeFileUri);
90+
91+
assert.ok(result, 'Result should not be undefined');
92+
assert.strictEqual(result!.action, ADD_PROJECT_ACTION);
93+
if (result!.action === ADD_PROJECT_ACTION) {
94+
assert.strictEqual(result!.fileUri, activeFileUri);
95+
}
96+
});
97+
98+
test('should return projects result when a project is selected', async () => {
99+
showQuickPickWithButtonsStub.callsFake((items: unknown[]) => {
100+
// Simulate selecting the first project item (index 4, after separators + action items)
101+
return Promise.resolve(items[4]);
102+
});
103+
104+
const result = await pickProjectWithCurrentFile([project1, project2], activeFileUri);
105+
106+
assert.ok(result, 'Result should not be undefined');
107+
assert.strictEqual(result!.action, 'projects');
108+
if (result!.action === 'projects') {
109+
assert.strictEqual(result!.projects.length, 1);
110+
assert.strictEqual(result!.projects[0], project1);
111+
}
112+
});
113+
114+
test('should return undefined when picker is cancelled', async () => {
115+
showQuickPickWithButtonsStub.resolves(undefined);
116+
117+
const result = await pickProjectWithCurrentFile([project1], activeFileUri);
118+
119+
assert.strictEqual(result, undefined, 'Should return undefined when cancelled');
120+
});
121+
122+
test('should use ignoreFocusOut in picker options', async () => {
123+
showQuickPickWithButtonsStub.resolves(undefined);
124+
125+
await pickProjectWithCurrentFile([project1], activeFileUri);
126+
127+
const options = showQuickPickWithButtonsStub.firstCall.args[1];
128+
assert.strictEqual(options.ignoreFocusOut, true, 'ignoreFocusOut should be true');
129+
});
130+
131+
test('should not use canPickMany', async () => {
132+
showQuickPickWithButtonsStub.resolves(undefined);
133+
134+
await pickProjectWithCurrentFile([project1], activeFileUri);
135+
136+
const options = showQuickPickWithButtonsStub.firstCall.args[1];
137+
assert.strictEqual(options.canPickMany, undefined, 'canPickMany should not be set');
138+
});
139+
});

0 commit comments

Comments
 (0)