Skip to content

Commit 1533818

Browse files
author
Kartik Raj
authored
Added option to run multiple Python files in separate terminals (#21223)
Closes #21215 #14094 Added the option to assign a dedicated terminal for each Python file: ![image](https://github.com/microsoft/vscode-python/assets/13199757/b01248e4-c826-4de0-b15f-cde959965e68)
1 parent eb9fde3 commit 1533818

File tree

11 files changed

+147
-47
lines changed

11 files changed

+147
-47
lines changed

package.json

+19
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,12 @@
304304
"icon": "$(play)",
305305
"title": "%python.command.python.execInTerminalIcon.title%"
306306
},
307+
{
308+
"category": "Python",
309+
"command": "python.execInNewTerminal",
310+
"icon": "$(play)",
311+
"title": "%python.command.python.execInNewTerminal.title%"
312+
},
307313
{
308314
"category": "Python",
309315
"command": "python.debugInTerminal",
@@ -1626,6 +1632,13 @@
16261632
"title": "%python.command.python.execInTerminalIcon.title%",
16271633
"when": "false && editorLangId == python"
16281634
},
1635+
{
1636+
"category": "Python",
1637+
"command": "python.execInNewTerminal",
1638+
"icon": "$(play)",
1639+
"title": "%python.command.python.execInNewTerminal.title%",
1640+
"when": "false && editorLangId == python"
1641+
},
16291642
{
16301643
"category": "Python",
16311644
"command": "python.debugInTerminal",
@@ -1784,6 +1797,12 @@
17841797
"title": "%python.command.python.execInTerminalIcon.title%",
17851798
"when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported"
17861799
},
1800+
{
1801+
"command": "python.execInNewTerminal",
1802+
"group": "navigation@0",
1803+
"title": "%python.command.python.execInNewTerminal.title%",
1804+
"when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported"
1805+
},
17871806
{
17881807
"command": "python.debugInTerminal",
17891808
"group": "navigation@1",

package.nls.json

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"python.command.python.execInTerminal.title": "Run Python File in Terminal",
88
"python.command.python.debugInTerminal.title": "Debug Python File",
99
"python.command.python.execInTerminalIcon.title": "Run Python File",
10+
"python.command.python.execInNewTerminal.title": "Run Python File in Separate Terminal",
1011
"python.command.python.setInterpreter.title": "Select Interpreter",
1112
"python.command.python.clearWorkspaceInterpreter.title": "Clear Workspace Interpreter Setting",
1213
"python.command.python.viewOutput.title": "Show Output",

src/client/common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export namespace Commands {
4444
export const Enable_SourceMap_Support = 'python.enableSourceMapSupport';
4545
export const Exec_In_Terminal = 'python.execInTerminal';
4646
export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon';
47+
export const Exec_In_Separate_Terminal = 'python.execInNewTerminal';
4748
export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell';
4849
export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal';
4950
export const GetSelectedInterpreterPath = 'python.interpreterPath';

src/client/common/terminal/factory.ts

+11-5
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ export class TerminalServiceFactory implements ITerminalServiceFactory {
2424
) {
2525
this.terminalServices = new Map<string, TerminalService>();
2626
}
27-
public getTerminalService(options: TerminalCreationOptions): ITerminalService {
27+
public getTerminalService(options: TerminalCreationOptions & { newTerminalPerFile?: boolean }): ITerminalService {
2828
const resource = options?.resource;
2929
const title = options?.title;
3030
let terminalTitle = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python';
3131
const interpreter = options?.interpreter;
32-
const id = this.getTerminalId(terminalTitle, resource, interpreter);
32+
const id = this.getTerminalId(terminalTitle, resource, interpreter, options.newTerminalPerFile);
3333
if (!this.terminalServices.has(id)) {
34-
if (this.terminalServices.size >= 1 && resource) {
34+
if (resource && options.newTerminalPerFile) {
3535
terminalTitle = `${terminalTitle}: ${path.basename(resource.fsPath).replace('.py', '')}`;
3636
}
3737
options.title = terminalTitle;
@@ -51,13 +51,19 @@ export class TerminalServiceFactory implements ITerminalServiceFactory {
5151
title = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python';
5252
return new TerminalService(this.serviceContainer, { resource, title });
5353
}
54-
private getTerminalId(title: string, resource?: Uri, interpreter?: PythonEnvironment): string {
54+
private getTerminalId(
55+
title: string,
56+
resource?: Uri,
57+
interpreter?: PythonEnvironment,
58+
newTerminalPerFile?: boolean,
59+
): string {
5560
if (!resource && !interpreter) {
5661
return title;
5762
}
5863
const workspaceFolder = this.serviceContainer
5964
.get<IWorkspaceService>(IWorkspaceService)
6065
.getWorkspaceFolder(resource || undefined);
61-
return `${title}:${workspaceFolder?.uri.fsPath || ''}:${interpreter?.path}:${resource?.fsPath || ''}`;
66+
const fileId = resource && newTerminalPerFile ? resource.fsPath : '';
67+
return `${title}:${workspaceFolder?.uri.fsPath || ''}:${interpreter?.path}:${fileId}`;
6268
}
6369
}

src/client/common/terminal/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export interface ITerminalServiceFactory {
9797
* @returns {ITerminalService}
9898
* @memberof ITerminalServiceFactory
9999
*/
100-
getTerminalService(options: TerminalCreationOptions): ITerminalService;
100+
getTerminalService(options: TerminalCreationOptions & { newTerminalPerFile?: boolean }): ITerminalService;
101101
createTerminalService(resource?: Uri, title?: string): ITerminalService;
102102
}
103103

src/client/telemetry/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,12 @@ export interface IEventNamePropertyMapping {
825825
* @type {('command' | 'icon')}
826826
*/
827827
trigger?: 'command' | 'icon';
828+
/**
829+
* Whether user chose to execute this Python file in a separate terminal or not.
830+
*
831+
* @type {boolean}
832+
*/
833+
newTerminalPerFile?: boolean;
828834
};
829835
/**
830836
* Telemetry Event sent when user executes code against Django Shell.

src/client/terminals/codeExecution/codeExecutionManager.ts

+35-21
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,31 @@ export class CodeExecutionManager implements ICodeExecutionManager {
3636
}
3737

3838
public registerCommands() {
39-
[Commands.Exec_In_Terminal, Commands.Exec_In_Terminal_Icon].forEach((cmd) => {
40-
this.disposableRegistry.push(
41-
this.commandManager.registerCommand(cmd as any, async (file: Resource) => {
42-
const interpreterService = this.serviceContainer.get<IInterpreterService>(IInterpreterService);
43-
const interpreter = await interpreterService.getActiveInterpreter(file);
44-
if (!interpreter) {
45-
this.commandManager.executeCommand(Commands.TriggerEnvironmentSelection, file).then(noop, noop);
46-
return;
47-
}
48-
const trigger = cmd === Commands.Exec_In_Terminal ? 'command' : 'icon';
49-
await this.executeFileInTerminal(file, trigger)
50-
.then(() => {
51-
if (this.shouldTerminalFocusOnStart(file))
52-
this.commandManager.executeCommand('workbench.action.terminal.focus');
39+
[Commands.Exec_In_Terminal, Commands.Exec_In_Terminal_Icon, Commands.Exec_In_Separate_Terminal].forEach(
40+
(cmd) => {
41+
this.disposableRegistry.push(
42+
this.commandManager.registerCommand(cmd as any, async (file: Resource) => {
43+
const interpreterService = this.serviceContainer.get<IInterpreterService>(IInterpreterService);
44+
const interpreter = await interpreterService.getActiveInterpreter(file);
45+
if (!interpreter) {
46+
this.commandManager
47+
.executeCommand(Commands.TriggerEnvironmentSelection, file)
48+
.then(noop, noop);
49+
return;
50+
}
51+
const trigger = cmd === Commands.Exec_In_Terminal ? 'command' : 'icon';
52+
await this.executeFileInTerminal(file, trigger, {
53+
newTerminalPerFile: cmd === Commands.Exec_In_Separate_Terminal,
5354
})
54-
.catch((ex) => traceError('Failed to execute file in terminal', ex));
55-
}),
56-
);
57-
});
55+
.then(() => {
56+
if (this.shouldTerminalFocusOnStart(file))
57+
this.commandManager.executeCommand('workbench.action.terminal.focus');
58+
})
59+
.catch((ex) => traceError('Failed to execute file in terminal', ex));
60+
}),
61+
);
62+
},
63+
);
5864
this.disposableRegistry.push(
5965
this.commandManager.registerCommand(Commands.Exec_Selection_In_Terminal as any, async (file: Resource) => {
6066
const interpreterService = this.serviceContainer.get<IInterpreterService>(IInterpreterService);
@@ -87,8 +93,16 @@ export class CodeExecutionManager implements ICodeExecutionManager {
8793
),
8894
);
8995
}
90-
private async executeFileInTerminal(file: Resource, trigger: 'command' | 'icon') {
91-
sendTelemetryEvent(EventName.EXECUTION_CODE, undefined, { scope: 'file', trigger });
96+
private async executeFileInTerminal(
97+
file: Resource,
98+
trigger: 'command' | 'icon',
99+
options?: { newTerminalPerFile: boolean },
100+
): Promise<void> {
101+
sendTelemetryEvent(EventName.EXECUTION_CODE, undefined, {
102+
scope: 'file',
103+
trigger,
104+
newTerminalPerFile: options?.newTerminalPerFile,
105+
});
92106
const codeExecutionHelper = this.serviceContainer.get<ICodeExecutionHelper>(ICodeExecutionHelper);
93107
file = file instanceof Uri ? file : undefined;
94108
let fileToExecute = file ? file : await codeExecutionHelper.getFileToExecute();
@@ -110,7 +124,7 @@ export class CodeExecutionManager implements ICodeExecutionManager {
110124
}
111125

112126
const executionService = this.serviceContainer.get<ICodeExecutionService>(ICodeExecutionService, 'standard');
113-
await executionService.executeFile(fileToExecute);
127+
await executionService.executeFile(fileToExecute, options);
114128
}
115129

116130
@captureTelemetry(EventName.EXECUTION_CODE, { scope: 'selection' }, false)

src/client/terminals/codeExecution/terminalCodeExecution.ts

+9-10
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ICodeExecutionService } from '../../terminals/types';
1919
export class TerminalCodeExecutionProvider implements ICodeExecutionService {
2020
private hasRanOutsideCurrentDrive = false;
2121
protected terminalTitle!: string;
22-
private replActive = new Map<string, Promise<boolean>>();
22+
private replActive?: Promise<boolean>;
2323
constructor(
2424
@inject(ITerminalServiceFactory) protected readonly terminalServiceFactory: ITerminalServiceFactory,
2525
@inject(IConfigurationService) protected readonly configurationService: IConfigurationService,
@@ -29,13 +29,13 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {
2929
@inject(IInterpreterService) protected readonly interpreterService: IInterpreterService,
3030
) {}
3131

32-
public async executeFile(file: Uri) {
32+
public async executeFile(file: Uri, options?: { newTerminalPerFile: boolean }) {
3333
await this.setCwdForFileExecution(file);
3434
const { command, args } = await this.getExecuteFileArgs(file, [
3535
file.fsPath.fileToCommandArgumentForPythonExt(),
3636
]);
3737

38-
await this.getTerminalService(file).sendCommand(command, args);
38+
await this.getTerminalService(file, options).sendCommand(command, args);
3939
}
4040

4141
public async execute(code: string, resource?: Uri): Promise<void> {
@@ -48,26 +48,24 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {
4848
}
4949
public async initializeRepl(resource?: Uri) {
5050
const terminalService = this.getTerminalService(resource);
51-
let replActive = this.replActive.get(resource?.fsPath || '');
52-
if (replActive && (await replActive)) {
51+
if (this.replActive && (await this.replActive)) {
5352
await terminalService.show();
5453
return;
5554
}
56-
replActive = new Promise<boolean>(async (resolve) => {
55+
this.replActive = new Promise<boolean>(async (resolve) => {
5756
const replCommandArgs = await this.getExecutableInfo(resource);
5857
terminalService.sendCommand(replCommandArgs.command, replCommandArgs.args);
5958

6059
// Give python repl time to start before we start sending text.
6160
setTimeout(() => resolve(true), 1000);
6261
});
63-
this.replActive.set(resource?.fsPath || '', replActive);
6462
this.disposables.push(
6563
terminalService.onDidCloseTerminal(() => {
66-
this.replActive.delete(resource?.fsPath || '');
64+
this.replActive = undefined;
6765
}),
6866
);
6967

70-
await replActive;
68+
await this.replActive;
7169
}
7270

7371
public async getExecutableInfo(resource?: Uri, args: string[] = []): Promise<PythonExecInfo> {
@@ -83,10 +81,11 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService {
8381
public async getExecuteFileArgs(resource?: Uri, executeArgs: string[] = []): Promise<PythonExecInfo> {
8482
return this.getExecutableInfo(resource, executeArgs);
8583
}
86-
private getTerminalService(resource?: Uri): ITerminalService {
84+
private getTerminalService(resource?: Uri, options?: { newTerminalPerFile: boolean }): ITerminalService {
8785
return this.terminalServiceFactory.getTerminalService({
8886
resource,
8987
title: this.terminalTitle,
88+
newTerminalPerFile: options?.newTerminalPerFile,
9089
});
9190
}
9291
private async setCwdForFileExecution(file: Uri) {

src/client/terminals/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const ICodeExecutionService = Symbol('ICodeExecutionService');
88

99
export interface ICodeExecutionService {
1010
execute(code: string, resource?: Uri): Promise<void>;
11-
executeFile(file: Uri): Promise<void>;
11+
executeFile(file: Uri, options?: { newTerminalPerFile: boolean }): Promise<void>;
1212
initializeRepl(resource?: Uri): Promise<void>;
1313
}
1414

src/test/common/terminals/factory.unit.test.ts

+46-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ suite('Terminal Service Factory', () => {
105105
expect(notSameAsThirdInstance).to.not.equal(true, 'Instances are the same');
106106
});
107107

108-
test('Ensure different terminal is returned when using different resources from the same workspace', () => {
108+
test('Ensure same terminal is returned when using different resources from the same workspace', () => {
109109
const file1A = Uri.file('1a');
110110
const file2A = Uri.file('2a');
111111
const fileB = Uri.file('b');
@@ -130,6 +130,51 @@ suite('Terminal Service Factory', () => {
130130
const terminalForFile2A = factory.getTerminalService({ resource: file2A }) as SynchronousTerminalService;
131131
const terminalForFileB = factory.getTerminalService({ resource: fileB }) as SynchronousTerminalService;
132132

133+
const terminalsAreSameForWorkspaceA = terminalForFile1A.terminalService === terminalForFile2A.terminalService;
134+
expect(terminalsAreSameForWorkspaceA).to.equal(true, 'Instances are not the same for Workspace A');
135+
136+
const terminalsForWorkspaceABAreDifferent =
137+
terminalForFile1A.terminalService === terminalForFileB.terminalService;
138+
expect(terminalsForWorkspaceABAreDifferent).to.equal(
139+
false,
140+
'Instances should be different for different workspaces',
141+
);
142+
});
143+
144+
test('When `newTerminalPerFile` is true, ensure different terminal is returned when using different resources from the same workspace', () => {
145+
const file1A = Uri.file('1a');
146+
const file2A = Uri.file('2a');
147+
const fileB = Uri.file('b');
148+
const workspaceUriA = Uri.file('A');
149+
const workspaceUriB = Uri.file('B');
150+
const workspaceFolderA = TypeMoq.Mock.ofType<WorkspaceFolder>();
151+
workspaceFolderA.setup((w) => w.uri).returns(() => workspaceUriA);
152+
const workspaceFolderB = TypeMoq.Mock.ofType<WorkspaceFolder>();
153+
workspaceFolderB.setup((w) => w.uri).returns(() => workspaceUriB);
154+
155+
workspaceService
156+
.setup((w) => w.getWorkspaceFolder(TypeMoq.It.isValue(file1A)))
157+
.returns(() => workspaceFolderA.object);
158+
workspaceService
159+
.setup((w) => w.getWorkspaceFolder(TypeMoq.It.isValue(file2A)))
160+
.returns(() => workspaceFolderA.object);
161+
workspaceService
162+
.setup((w) => w.getWorkspaceFolder(TypeMoq.It.isValue(fileB)))
163+
.returns(() => workspaceFolderB.object);
164+
165+
const terminalForFile1A = factory.getTerminalService({
166+
resource: file1A,
167+
newTerminalPerFile: true,
168+
}) as SynchronousTerminalService;
169+
const terminalForFile2A = factory.getTerminalService({
170+
resource: file2A,
171+
newTerminalPerFile: true,
172+
}) as SynchronousTerminalService;
173+
const terminalForFileB = factory.getTerminalService({
174+
resource: fileB,
175+
newTerminalPerFile: true,
176+
}) as SynchronousTerminalService;
177+
133178
const terminalsAreSameForWorkspaceA = terminalForFile1A.terminalService === terminalForFile2A.terminalService;
134179
expect(terminalsAreSameForWorkspaceA).to.equal(false, 'Instances are the same for Workspace A');
135180

src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts

+17-8
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,15 @@ suite('Terminal - Code Execution Manager', () => {
7777
executionManager.registerCommands();
7878

7979
const sorted = registered.sort();
80-
expect(sorted).to.deep.equal([
81-
Commands.Exec_In_Terminal,
82-
Commands.Exec_In_Terminal_Icon,
83-
Commands.Exec_Selection_In_Django_Shell,
84-
Commands.Exec_Selection_In_Terminal,
85-
]);
80+
expect(sorted).to.deep.equal(
81+
[
82+
Commands.Exec_In_Separate_Terminal,
83+
Commands.Exec_In_Terminal,
84+
Commands.Exec_In_Terminal_Icon,
85+
Commands.Exec_Selection_In_Django_Shell,
86+
Commands.Exec_Selection_In_Terminal,
87+
].sort(),
88+
);
8689
});
8790

8891
test('Ensure executeFileInterTerminal will do nothing if no file is avialble', async () => {
@@ -135,7 +138,10 @@ suite('Terminal - Code Execution Manager', () => {
135138
const fileToExecute = Uri.file('x');
136139
await commandHandler!(fileToExecute);
137140
helper.verify(async (h) => h.getFileToExecute(), TypeMoq.Times.never());
138-
executionService.verify(async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute)), TypeMoq.Times.once());
141+
executionService.verify(
142+
async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute), TypeMoq.It.isAny()),
143+
TypeMoq.Times.once(),
144+
);
139145
});
140146

141147
test('Ensure executeFileInterTerminal will use active file', async () => {
@@ -164,7 +170,10 @@ suite('Terminal - Code Execution Manager', () => {
164170
.returns(() => executionService.object);
165171

166172
await commandHandler!(fileToExecute);
167-
executionService.verify(async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute)), TypeMoq.Times.once());
173+
executionService.verify(
174+
async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute), TypeMoq.It.isAny()),
175+
TypeMoq.Times.once(),
176+
);
168177
});
169178

170179
async function testExecutionOfSelectionWithoutAnyActiveDocument(commandId: string, executionSericeId: string) {

0 commit comments

Comments
 (0)