diff --git a/package-lock.json b/package-lock.json index bde67795d468..73b3b4431bb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@iarna/toml": "^2.2.5", "@vscode/extension-telemetry": "^0.8.4", "arch": "^2.1.0", - "diff-match-patch": "^1.0.5", "fs-extra": "^10.0.1", "glob": "^7.2.0", "hash.js": "^1.1.7", @@ -4637,11 +4636,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" - }, "node_modules/diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", @@ -17608,11 +17602,6 @@ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true }, - "diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" - }, "diffie-hellman": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", diff --git a/package.json b/package.json index 0b2def5937f6..a43bb33944e2 100644 --- a/package.json +++ b/package.json @@ -344,7 +344,12 @@ { "category": "Python", "command": "python.startREPL", - "title": "%python.command.python.startREPL.title%" + "title": "%python.command.python.startTerminalREPL.title%" + }, + { + "category": "Python", + "command": "python.startNativeREPL", + "title": "%python.command.python.startNativeREPL.title%" }, { "category": "Python", @@ -1328,7 +1333,13 @@ { "category": "Python", "command": "python.startREPL", - "title": "%python.command.python.startREPL.title%", + "title": "%python.command.python.startTerminalREPL.title%", + "when": "!virtualWorkspace && shellExecutionSupported" + }, + { + "category": "Python", + "command": "python.startNativeREPL", + "title": "%python.command.python.startNativeREPL.title%", "when": "!virtualWorkspace && shellExecutionSupported" }, { diff --git a/package.nls.json b/package.nls.json index dcf8a2ddf5f9..5a5029231b17 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,5 +1,6 @@ { - "python.command.python.startREPL.title": "Start Terminal REPL", + "python.command.python.startTerminalREPL.title": "Start Terminal REPL", + "python.command.python.startNativeREPL.title": "Start Native Python REPL", "python.command.python.createEnvironment.title": "Create Environment...", "python.command.python.createNewFile.title": "New Python File", "python.command.python.createTerminal.title": "Create Terminal", diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index 388bcf8052fa..4580a91a78d1 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -96,6 +96,7 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu ['workbench.action.openIssueReporter']: [{ extensionId: string; issueBody: string }]; [Commands.GetSelectedInterpreterPath]: [{ workspaceFolder: string } | string[]]; [Commands.TriggerEnvironmentSelection]: [undefined | Uri]; + [Commands.Start_Native_REPL]: [undefined | Uri]; [Commands.Exec_In_REPL]: [undefined | Uri]; [Commands.Exec_In_REPL_Enter]: [undefined | Uri]; [Commands.Exec_In_Terminal]: [undefined, Uri]; diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index d5b82f68ae97..23e9c131b25c 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -62,6 +62,7 @@ export namespace Commands { export const Set_Interpreter = 'python.setInterpreter'; export const Set_ShebangInterpreter = 'python.setShebangInterpreter'; export const Start_REPL = 'python.startREPL'; + export const Start_Native_REPL = 'python.startNativeREPL'; export const Tests_Configure = 'python.configureTests'; export const TriggerEnvironmentSelection = 'python.triggerEnvSelection'; export const ViewOutput = 'python.viewOutput'; diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 6f2a4565299f..77ed2edf6716 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -52,7 +52,7 @@ import { initializePersistentStateForTriggers } from './common/persistentState'; import { logAndNotifyOnLegacySettings } from './logging/settingLogs'; import { DebuggerTypeName } from './debugger/constants'; import { StopWatch } from './common/utils/stopWatch'; -import { registerReplCommands, registerReplExecuteOnEnter } from './repl/replCommands'; +import { registerReplCommands, registerReplExecuteOnEnter, registerStartNativeReplCommand } from './repl/replCommands'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -108,6 +108,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components): ); const executionHelper = ext.legacyIOC.serviceContainer.get(ICodeExecutionHelper); const commandManager = ext.legacyIOC.serviceContainer.get(ICommandManager); + registerStartNativeReplCommand(ext.disposables, interpreterService); registerReplCommands(ext.disposables, interpreterService, executionHelper, commandManager); registerReplExecuteOnEnter(ext.disposables, interpreterService, commandManager); } diff --git a/src/client/repl/nativeRepl.ts b/src/client/repl/nativeRepl.ts index e6a596f4434a..e28d21228666 100644 --- a/src/client/repl/nativeRepl.ts +++ b/src/client/repl/nativeRepl.ts @@ -1,32 +1,51 @@ // Native Repl class that holds instance of pythonServer and replController -import { NotebookController, NotebookControllerAffinity, NotebookDocument, TextEditor, workspace } from 'vscode'; +import { + NotebookController, + NotebookControllerAffinity, + NotebookDocument, + QuickPickItem, + TextEditor, + workspace, + WorkspaceFolder, +} from 'vscode'; import { Disposable } from 'vscode-jsonrpc'; import { PVSC_EXTENSION_ID } from '../common/constants'; +import { showQuickPick } from '../common/vscodeApis/windowApis'; +import { getWorkspaceFolders } from '../common/vscodeApis/workspaceApis'; import { PythonEnvironment } from '../pythonEnvironments/info'; import { createPythonServer, PythonServer } from './pythonServer'; import { executeNotebookCell, openInteractiveREPL, selectNotebookKernel } from './replCommandHandler'; import { createReplController } from './replController'; export class NativeRepl implements Disposable { - private pythonServer: PythonServer; + // Adding ! since it will get initialized in create method, not the constructor. + private pythonServer!: PythonServer; - private interpreter: PythonEnvironment; + private cwd: string | undefined; + + private interpreter!: PythonEnvironment; private disposables: Disposable[] = []; - private replController: NotebookController; + private replController!: NotebookController; private notebookDocument: NotebookDocument | undefined; // TODO: In the future, could also have attribute of URI for file specific REPL. - constructor(interpreter: PythonEnvironment) { - this.interpreter = interpreter; + private constructor() { + this.watchNotebookClosed(); + } - this.pythonServer = createPythonServer([interpreter.path as string]); - this.replController = this.setReplController(); + // Static async factory method to handle asynchronous initialization + public static async create(interpreter: PythonEnvironment): Promise { + const nativeRepl = new NativeRepl(); + nativeRepl.interpreter = interpreter; + await nativeRepl.setReplDirectory(); + nativeRepl.pythonServer = createPythonServer([interpreter.path as string], nativeRepl.cwd); + nativeRepl.replController = nativeRepl.setReplController(); - this.watchNotebookClosed(); + return nativeRepl; } dispose(): void { @@ -47,13 +66,46 @@ export class NativeRepl implements Disposable { ); } + /** + * Function that set up desired directory for REPL. + * If there is multiple workspaces, prompt the user to choose + * which directory we should set in context of native REPL. + */ + private async setReplDirectory(): Promise { + // Figure out uri via workspaceFolder as uri parameter always + // seem to be undefined from parameter when trying to access from replCommands.ts + const workspaces: readonly WorkspaceFolder[] | undefined = getWorkspaceFolders(); + + if (workspaces) { + // eslint-disable-next-line no-shadow + const workspacesQuickPickItems: QuickPickItem[] = workspaces.map((workspace) => ({ + label: workspace.name, + description: workspace.uri.fsPath, + })); + + if (workspacesQuickPickItems.length === 0) { + this.cwd = process.cwd(); // Yields '/' on no workspace scenario. + } else if (workspacesQuickPickItems.length === 1) { + this.cwd = workspacesQuickPickItems[0].description; + } else { + // Show choices of workspaces for user to choose from. + const selection = (await showQuickPick(workspacesQuickPickItems, { + placeHolder: 'Select current working directory for new REPL', + matchOnDescription: true, + ignoreFocusOut: true, + })) as QuickPickItem; + this.cwd = selection?.description; + } + } + } + /** * Function that check if NotebookController for REPL exists, and returns it in Singleton manner. * @returns NotebookController */ public setReplController(): NotebookController { if (!this.replController) { - return createReplController(this.interpreter.path, this.disposables); + return createReplController(this.interpreter!.path, this.disposables, this.cwd); } return this.replController; } @@ -84,14 +136,16 @@ export class NativeRepl implements Disposable { * Function that opens interactive repl, selects kernel, and send/execute code to the native repl. * @param code */ - public async sendToNativeRepl(code: string): Promise { + public async sendToNativeRepl(code?: string): Promise { const notebookEditor = await openInteractiveREPL(this.replController, this.notebookDocument); this.notebookDocument = notebookEditor.notebook; if (this.notebookDocument) { this.replController.updateNotebookAffinity(this.notebookDocument, NotebookControllerAffinity.Default); await selectNotebookKernel(notebookEditor, this.replController.id, PVSC_EXTENSION_ID); - await executeNotebookCell(this.notebookDocument, code); + if (code) { + await executeNotebookCell(this.notebookDocument, code); + } } } } @@ -103,9 +157,9 @@ let nativeRepl: NativeRepl | undefined; // In multi REPL scenario, hashmap of UR * @param interpreter * @returns Native REPL instance */ -export function getNativeRepl(interpreter: PythonEnvironment, disposables: Disposable[]): NativeRepl { +export async function getNativeRepl(interpreter: PythonEnvironment, disposables: Disposable[]): Promise { if (!nativeRepl) { - nativeRepl = new NativeRepl(interpreter); + nativeRepl = await NativeRepl.create(interpreter); disposables.push(nativeRepl); } return nativeRepl; diff --git a/src/client/repl/pythonServer.ts b/src/client/repl/pythonServer.ts index ca45ea900baf..fbcb1104dc69 100644 --- a/src/client/repl/pythonServer.ts +++ b/src/client/repl/pythonServer.ts @@ -89,12 +89,14 @@ class PythonServerImpl implements Disposable { } } -export function createPythonServer(interpreter: string[]): PythonServer { +export function createPythonServer(interpreter: string[], cwd?: string): PythonServer { if (serverInstance) { return serverInstance; } - const pythonServer = ch.spawn(interpreter[0], [...interpreter.slice(1), SERVER_PATH]); + const pythonServer = ch.spawn(interpreter[0], [...interpreter.slice(1), SERVER_PATH], { + cwd, // Launch with correct workspace directory + }); pythonServer.stderr.on('data', (data) => { traceError(data.toString()); diff --git a/src/client/repl/replCommands.ts b/src/client/repl/replCommands.ts index c3f167ff51cc..5570fa8384f4 100644 --- a/src/client/repl/replCommands.ts +++ b/src/client/repl/replCommands.ts @@ -14,6 +14,32 @@ import { insertNewLineToREPLInput, isMultiLineText, } from './replUtils'; +import { registerCommand } from '../common/vscodeApis/commandApis'; + +/** + * Register Start Native REPL command in the command palette + * + * @param disposables + * @param interpreterService + * @param commandManager + * @returns Promise + */ +export async function registerStartNativeReplCommand( + disposables: Disposable[], + interpreterService: IInterpreterService, +): Promise { + disposables.push( + registerCommand(Commands.Start_Native_REPL, async (uri: Uri) => { + const interpreter = await getActiveInterpreter(uri, interpreterService); + if (interpreter) { + if (interpreter) { + const nativeRepl = await getNativeRepl(interpreter, disposables); + await nativeRepl.sendToNativeRepl(); + } + } + }), + ); +} /** * Registers REPL command for shift+enter if sendToNativeREPL setting is enabled. @@ -39,7 +65,7 @@ export async function registerReplCommands( const interpreter = await getActiveInterpreter(uri, interpreterService); if (interpreter) { - const nativeRepl = getNativeRepl(interpreter, disposables); + const nativeRepl = await getNativeRepl(interpreter, disposables); const activeEditor = window.activeTextEditor; if (activeEditor) { const code = await getSelectedTextToExecute(activeEditor); @@ -76,7 +102,7 @@ export async function registerReplExecuteOnEnter( return; } - const nativeRepl = getNativeRepl(interpreter, disposables); + const nativeRepl = await getNativeRepl(interpreter, disposables); const completeCode = await nativeRepl?.checkUserInputCompleteCode(window.activeTextEditor); const editor = window.activeTextEditor; diff --git a/src/client/repl/replController.ts b/src/client/repl/replController.ts index 4760edc98036..7c1f8fd0c6b2 100644 --- a/src/client/repl/replController.ts +++ b/src/client/repl/replController.ts @@ -4,8 +4,9 @@ import { createPythonServer } from './pythonServer'; export function createReplController( interpreterPath: string, disposables: vscode.Disposable[], + cwd?: string, ): vscode.NotebookController { - const server = createPythonServer([interpreterPath]); + const server = createPythonServer([interpreterPath], cwd); disposables.push(server); const controller = vscode.notebooks.createNotebookController('pythonREPL', 'interactive', 'Python REPL'); diff --git a/src/test/repl/nativeRepl.test.ts b/src/test/repl/nativeRepl.test.ts new file mode 100644 index 000000000000..0fc55abe1a64 --- /dev/null +++ b/src/test/repl/nativeRepl.test.ts @@ -0,0 +1,70 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as TypeMoq from 'typemoq'; +import * as sinon from 'sinon'; +import { Disposable } from 'vscode'; +import { expect } from 'chai'; + +import { IInterpreterService } from '../../client/interpreter/contracts'; +import { PythonEnvironment } from '../../client/pythonEnvironments/info'; +import { getNativeRepl, NativeRepl } from '../../client/repl/nativeRepl'; + +suite('REPL - Native REPL', () => { + let interpreterService: TypeMoq.IMock; + + let disposable: TypeMoq.IMock; + let disposableArray: Disposable[] = []; + + let setReplDirectoryStub: sinon.SinonStub; + let setReplControllerSpy: sinon.SinonSpy; + + setup(() => { + interpreterService = TypeMoq.Mock.ofType(); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment)); + disposable = TypeMoq.Mock.ofType(); + disposableArray = [disposable.object]; + + setReplDirectoryStub = sinon.stub(NativeRepl.prototype as any, 'setReplDirectory').resolves(); // Stubbing private method + // Use a spy instead of a stub for setReplController + setReplControllerSpy = sinon.spy(NativeRepl.prototype, 'setReplController'); + }); + + teardown(() => { + disposableArray.forEach((d) => { + if (d) { + d.dispose(); + } + }); + + disposableArray = []; + sinon.restore(); + }); + + test('getNativeRepl should call create constructor', async () => { + const createMethodStub = sinon.stub(NativeRepl, 'create'); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment)); + const interpreter = await interpreterService.object.getActiveInterpreter(); + await getNativeRepl(interpreter as PythonEnvironment, disposableArray); + + expect(createMethodStub.calledOnce).to.be.true; + }); + + test('create should call setReplDirectory, setReplController', async () => { + const interpreter = await interpreterService.object.getActiveInterpreter(); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: 'ps' } as unknown) as PythonEnvironment)); + + await NativeRepl.create(interpreter as PythonEnvironment); + + expect(setReplDirectoryStub.calledOnce).to.be.true; + expect(setReplControllerSpy.calledOnce).to.be.true; + + setReplDirectoryStub.restore(); + setReplControllerSpy.restore(); + }); +});