diff --git a/package.json b/package.json index 49ec028f55ab..d1bd2ef146d0 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,11 @@ "title": "%python.command.python.startREPL.title%", "category": "Python" }, + { + "command": "python.createTerminal", + "title": "%python.command.python.createTerminal.title%", + "category": "Python" + }, { "command": "python.buildWorkspaceSymbols", "title": "%python.command.python.buildWorkspaceSymbols.title%", diff --git a/package.nls.json b/package.nls.json index 3313f7c1c07b..dd6cedc42d68 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,6 +1,7 @@ { "python.command.python.sortImports.title": "Sort Imports", "python.command.python.startREPL.title": "Start REPL", + "python.command.python.createTerminal.title": "Create Terminal", "python.command.python.buildWorkspaceSymbols.title": "Build Workspace Symbols", "python.command.python.runtests.title": "Run All Unit Tests", "python.command.python.debugtests.title": "Debug All Unit Tests", diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 2f2d7f2c960f..b577ba044753 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -28,6 +28,7 @@ export namespace Commands { export const Update_SparkLibrary = 'python.updateSparkLibrary'; export const Build_Workspace_Symbols = 'python.buildWorkspaceSymbols'; export const Start_REPL = 'python.startREPL'; + export const Create_Terminal = 'python.createTerminal'; export const Set_Linter = 'python.setLinter'; export const Enable_Linter = 'python.enableLinting'; } diff --git a/src/client/common/terminal/factory.ts b/src/client/common/terminal/factory.ts index a8ac07e4c114..39a6e850af74 100644 --- a/src/client/common/terminal/factory.ts +++ b/src/client/common/terminal/factory.ts @@ -27,6 +27,10 @@ export class TerminalServiceFactory implements ITerminalServiceFactory { return this.terminalServices.get(id)!; } + public createTerminalService(resource?: Uri, title?: string): ITerminalService { + const terminalTitle = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python'; + return new TerminalService(this.serviceContainer, resource, terminalTitle); + } private getTerminalId(title: string, resource?: Uri): string { if (!resource) { return title; diff --git a/src/client/common/terminal/types.ts b/src/client/common/terminal/types.ts index 656f11ae76a7..875c1460e255 100644 --- a/src/client/common/terminal/types.ts +++ b/src/client/common/terminal/types.ts @@ -18,7 +18,7 @@ export interface ITerminalService { readonly onDidCloseTerminal: Event; sendCommand(command: string, args: string[]): Promise; sendText(text: string): Promise; - show(): void; + show(): Promise; } export const ITerminalServiceFactory = Symbol('ITerminalServiceFactory'); @@ -33,6 +33,7 @@ export interface ITerminalServiceFactory { * @memberof ITerminalServiceFactory */ getTerminalService(resource?: Uri, title?: string): ITerminalService; + createTerminalService(resource?: Uri, title?: string): ITerminalService; } export const ITerminalHelper = Symbol('ITerminalHelper'); diff --git a/src/client/extension.ts b/src/client/extension.ts index c4e42c7c2f9f..9da8774ad0cc 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -7,8 +7,8 @@ if ((Reflect as any).metadata === undefined) { } import { Container } from 'inversify'; import * as os from 'os'; -import * as vscode from 'vscode'; import { Disposable, Memento, OutputChannel, window } from 'vscode'; +import * as vscode from 'vscode'; import { BannerService } from './banner'; import { PythonSettings } from './common/configSettings'; import * as settings from './common/configSettings'; @@ -47,6 +47,7 @@ import { ReplProvider } from './providers/replProvider'; import { PythonSignatureProvider } from './providers/signatureProvider'; import { activateSimplePythonRefactorProvider } from './providers/simpleRefactorProvider'; import { PythonSymbolProvider } from './providers/symbolProvider'; +import { TerminalProvider } from './providers/terminalProvider'; import { activateUpdateSparkLibraryProvider } from './providers/updateSparkLibraryProvider'; import * as sortImports from './sortImports'; import { sendTelemetryEvent } from './telemetry'; @@ -118,6 +119,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(...activateGoToObjectDefinitionProvider(jediFactory)); context.subscriptions.push(new ReplProvider(serviceContainer)); + context.subscriptions.push(new TerminalProvider(serviceContainer)); // Enable indentAction // tslint:disable-next-line:no-non-null-assertion diff --git a/src/client/providers/terminalProvider.ts b/src/client/providers/terminalProvider.ts new file mode 100644 index 000000000000..7bfb5bb4d08f --- /dev/null +++ b/src/client/providers/terminalProvider.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable, Uri } from 'vscode'; +import { ICommandManager, IDocumentManager, IWorkspaceService } from '../common/application/types'; +import { Commands } from '../common/constants'; +import { ITerminalServiceFactory } from '../common/terminal/types'; +import { IServiceContainer } from '../ioc/types'; + +export class TerminalProvider implements Disposable { + private disposables: Disposable[] = []; + constructor(private serviceContainer: IServiceContainer) { + this.registerCommands(); + } + public dispose() { + this.disposables.forEach(disposable => disposable.dispose()); + } + private registerCommands() { + const commandManager = this.serviceContainer.get(ICommandManager); + const disposable = commandManager.registerCommand(Commands.Create_Terminal, this.onCreateTerminal, this); + + this.disposables.push(disposable); + } + private async onCreateTerminal() { + const terminalService = this.serviceContainer.get(ITerminalServiceFactory); + const activeResource = this.getActiveResource(); + await terminalService.createTerminalService(activeResource, 'Python').show(); + } + private getActiveResource(): Uri | undefined { + const documentManager = this.serviceContainer.get(IDocumentManager); + if (documentManager.activeTextEditor && !documentManager.activeTextEditor.document.isUntitled) { + return documentManager.activeTextEditor.document.uri; + } + const workspace = this.serviceContainer.get(IWorkspaceService); + return Array.isArray(workspace.workspaceFolders) && workspace.workspaceFolders.length > 0 ? workspace.workspaceFolders[0].uri : undefined; + } +} diff --git a/src/test/common/terminals/factory.test.ts b/src/test/common/terminals/factory.test.ts index c3fc699708ce..96518c7ccfa3 100644 --- a/src/test/common/terminals/factory.test.ts +++ b/src/test/common/terminals/factory.test.ts @@ -6,6 +6,7 @@ import * as TypeMoq from 'typemoq'; import { Disposable, Uri, WorkspaceFolder } from 'vscode'; import { ITerminalManager, IWorkspaceService } from '../../../client/common/application/types'; import { TerminalServiceFactory } from '../../../client/common/terminal/factory'; +import { TerminalService } from '../../../client/common/terminal/service'; import { ITerminalHelper, ITerminalServiceFactory } from '../../../client/common/terminal/types'; import { IDisposableRegistry } from '../../../client/common/types'; import { IInterpreterService } from '../../../client/interpreter/contracts'; @@ -54,6 +55,8 @@ suite('Terminal Service Factory', () => { test('Ensure different instance of terminal service is returned when title is provided', () => { const defaultInstance = factory.getTerminalService(); + expect(defaultInstance instanceof TerminalService).to.equal(true, 'Not an instance of Terminal service'); + const notSameAsDefaultInstance = factory.getTerminalService(undefined, 'New Title') === defaultInstance; expect(notSameAsDefaultInstance).to.not.equal(true, 'Instances are the same as default instance'); @@ -66,6 +69,22 @@ suite('Terminal Service Factory', () => { expect(notTheSameInstance).not.to.equal(true, 'Instances are the same'); }); + test('Ensure different instance of terminal services are created', () => { + const instance1 = factory.createTerminalService(); + expect(instance1 instanceof TerminalService).to.equal(true, 'Not an instance of Terminal service'); + + const notSameAsFirstInstance = factory.createTerminalService() === instance1; + expect(notSameAsFirstInstance).to.not.equal(true, 'Instances are the same'); + + const instance2 = factory.createTerminalService(Uri.file('a'), 'Title'); + const notSameAsSecondInstance = instance1 === instance2; + expect(notSameAsSecondInstance).to.not.equal(true, 'Instances are the same'); + + const instance3 = factory.createTerminalService(Uri.file('a'), 'Title'); + const notSameAsThirdInstance = instance2 === instance3; + expect(notSameAsThirdInstance).to.not.equal(true, 'Instances are the same'); + }); + test('Ensure same terminal is returned when using resources from the same workspace', () => { const file1A = Uri.file('1a'); const file2A = Uri.file('2a'); diff --git a/src/test/providers/terminal.test.ts b/src/test/providers/terminal.test.ts new file mode 100644 index 000000000000..7b9097120d7e --- /dev/null +++ b/src/test/providers/terminal.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import * as TypeMoq from 'typemoq'; +import { Disposable, TextDocument, TextEditor, Uri, WorkspaceFolder } from 'vscode'; +import { ICommandManager, IDocumentManager, IWorkspaceService } from '../../client/common/application/types'; +import { Commands } from '../../client/common/constants'; +import { TerminalService } from '../../client/common/terminal/service'; +import { ITerminalServiceFactory } from '../../client/common/terminal/types'; +import { IServiceContainer } from '../../client/ioc/types'; +import { TerminalProvider } from '../../client/providers/terminalProvider'; + +// tslint:disable-next-line:max-func-body-length +suite('Terminal Provider', () => { + let serviceContainer: TypeMoq.IMock; + let commandManager: TypeMoq.IMock; + let workspace: TypeMoq.IMock; + let documentManager: TypeMoq.IMock; + let terminalProvider: TerminalProvider; + setup(() => { + serviceContainer = TypeMoq.Mock.ofType(); + commandManager = TypeMoq.Mock.ofType(); + workspace = TypeMoq.Mock.ofType(); + documentManager = TypeMoq.Mock.ofType(); + serviceContainer.setup(c => c.get(ICommandManager)).returns(() => commandManager.object); + serviceContainer.setup(c => c.get(IWorkspaceService)).returns(() => workspace.object); + serviceContainer.setup(c => c.get(IDocumentManager)).returns(() => documentManager.object); + }); + teardown(() => { + try { + terminalProvider.dispose(); + // tslint:disable-next-line:no-empty + } catch { } + }); + + test('Ensure command is registered', () => { + terminalProvider = new TerminalProvider(serviceContainer.object); + commandManager.verify(c => c.registerCommand(TypeMoq.It.isValue(Commands.Create_Terminal), TypeMoq.It.isAny(), TypeMoq.It.isAny()), TypeMoq.Times.once()); + }); + + test('Ensure command handler is disposed', () => { + const disposable = TypeMoq.Mock.ofType(); + commandManager.setup(c => c.registerCommand(TypeMoq.It.isValue(Commands.Create_Terminal), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => disposable.object); + + terminalProvider = new TerminalProvider(serviceContainer.object); + terminalProvider.dispose(); + + disposable.verify(d => d.dispose(), TypeMoq.Times.once()); + }); + + test('Ensure terminal is created and displayed when command is invoked', () => { + const disposable = TypeMoq.Mock.ofType(); + let commandHandler: undefined | (() => void); + commandManager.setup(c => c.registerCommand(TypeMoq.It.isValue(Commands.Create_Terminal), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_cmd, callback) => { + commandHandler = callback; + return disposable.object; + }); + documentManager.setup(d => d.activeTextEditor).returns(() => undefined); + workspace.setup(w => w.workspaceFolders).returns(() => undefined); + + terminalProvider = new TerminalProvider(serviceContainer.object); + expect(commandHandler).not.to.be.equal(undefined, 'Handler not set'); + + const terminalServiceFactory = TypeMoq.Mock.ofType(); + serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ITerminalServiceFactory))).returns(() => terminalServiceFactory.object); + const terminalService = TypeMoq.Mock.ofType(); + terminalServiceFactory.setup(t => t.createTerminalService(TypeMoq.It.isValue(undefined), TypeMoq.It.isValue('Python'))).returns(() => terminalService.object); + + commandHandler!.call(terminalProvider); + terminalService.verify(t => t.show(), TypeMoq.Times.once()); + }); + + test('Ensure terminal creation does not use uri of the active documents which is untitled', () => { + const disposable = TypeMoq.Mock.ofType(); + let commandHandler: undefined | (() => void); + commandManager.setup(c => c.registerCommand(TypeMoq.It.isValue(Commands.Create_Terminal), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_cmd, callback) => { + commandHandler = callback; + return disposable.object; + }); + const editor = TypeMoq.Mock.ofType(); + documentManager.setup(d => d.activeTextEditor).returns(() => editor.object); + const document = TypeMoq.Mock.ofType(); + document.setup(d => d.isUntitled).returns(() => true); + editor.setup(e => e.document).returns(() => document.object); + workspace.setup(w => w.workspaceFolders).returns(() => undefined); + + terminalProvider = new TerminalProvider(serviceContainer.object); + expect(commandHandler).not.to.be.equal(undefined, 'Handler not set'); + + const terminalServiceFactory = TypeMoq.Mock.ofType(); + serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ITerminalServiceFactory))).returns(() => terminalServiceFactory.object); + const terminalService = TypeMoq.Mock.ofType(); + terminalServiceFactory.setup(t => t.createTerminalService(TypeMoq.It.isValue(undefined), TypeMoq.It.isValue('Python'))).returns(() => terminalService.object); + + commandHandler!.call(terminalProvider); + terminalService.verify(t => t.show(), TypeMoq.Times.once()); + }); + + test('Ensure terminal creation uses uri of active document', () => { + const disposable = TypeMoq.Mock.ofType(); + let commandHandler: undefined | (() => void); + commandManager.setup(c => c.registerCommand(TypeMoq.It.isValue(Commands.Create_Terminal), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_cmd, callback) => { + commandHandler = callback; + return disposable.object; + }); + const editor = TypeMoq.Mock.ofType(); + documentManager.setup(d => d.activeTextEditor).returns(() => editor.object); + const document = TypeMoq.Mock.ofType(); + const documentUri = Uri.file('a'); + document.setup(d => d.isUntitled).returns(() => false); + document.setup(d => d.uri).returns(() => documentUri); + editor.setup(e => e.document).returns(() => document.object); + workspace.setup(w => w.workspaceFolders).returns(() => undefined); + + terminalProvider = new TerminalProvider(serviceContainer.object); + expect(commandHandler).not.to.be.equal(undefined, 'Handler not set'); + + const terminalServiceFactory = TypeMoq.Mock.ofType(); + serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ITerminalServiceFactory))).returns(() => terminalServiceFactory.object); + const terminalService = TypeMoq.Mock.ofType(); + terminalServiceFactory.setup(t => t.createTerminalService(TypeMoq.It.isValue(documentUri), TypeMoq.It.isValue('Python'))).returns(() => terminalService.object); + + commandHandler!.call(terminalProvider); + terminalService.verify(t => t.show(), TypeMoq.Times.once()); + }); + + test('Ensure terminal creation uses uri of active workspace', () => { + const disposable = TypeMoq.Mock.ofType(); + let commandHandler: undefined | (() => void); + commandManager.setup(c => c.registerCommand(TypeMoq.It.isValue(Commands.Create_Terminal), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((_cmd, callback) => { + commandHandler = callback; + return disposable.object; + }); + documentManager.setup(d => d.activeTextEditor).returns(() => undefined); + const workspaceUri = Uri.file('a'); + const workspaceFolder = TypeMoq.Mock.ofType(); + workspaceFolder.setup(w => w.uri).returns(() => workspaceUri); + workspace.setup(w => w.workspaceFolders).returns(() => [workspaceFolder.object]); + + terminalProvider = new TerminalProvider(serviceContainer.object); + expect(commandHandler).not.to.be.equal(undefined, 'Handler not set'); + + const terminalServiceFactory = TypeMoq.Mock.ofType(); + serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ITerminalServiceFactory))).returns(() => terminalServiceFactory.object); + const terminalService = TypeMoq.Mock.ofType(); + terminalServiceFactory.setup(t => t.createTerminalService(TypeMoq.It.isValue(workspaceUri), TypeMoq.It.isValue('Python'))).returns(() => terminalService.object); + + commandHandler!.call(terminalProvider); + terminalService.verify(t => t.show(), TypeMoq.Times.once()); + }); +});