diff --git a/python_files/pythonrc.py b/python_files/pythonrc.py index 13f374e023b8..0f552c86d375 100644 --- a/python_files/pythonrc.py +++ b/python_files/pythonrc.py @@ -77,3 +77,8 @@ def __str__(self): if sys.platform != "win32" and (not is_wsl) and use_shell_integration: sys.ps1 = PS1() + +if sys.platform == "darwin": + print("Cmd click to launch VS Code Native REPL") +else: + print("Ctrl click to launch VS Code Native REPL") diff --git a/python_files/tests/test_shell_integration.py b/python_files/tests/test_shell_integration.py index a7dfc2ff1a8f..376cb466bb50 100644 --- a/python_files/tests/test_shell_integration.py +++ b/python_files/tests/test_shell_integration.py @@ -61,3 +61,23 @@ def test_excepthook_call(): hooks.my_excepthook("mock_type", "mock_value", "mock_traceback") mock_excepthook.assert_called_once_with("mock_type", "mock_value", "mock_traceback") + + +if sys.platform == "darwin": + + def test_print_statement_darwin(monkeypatch): + importlib.reload(pythonrc) + with monkeypatch.context() as m: + m.setattr("builtins.print", Mock()) + importlib.reload(sys.modules["pythonrc"]) + print.assert_any_call("Cmd click to launch VS Code Native REPL") + + +if sys.platform == "win32": + + def test_print_statement_non_darwin(monkeypatch): + importlib.reload(pythonrc) + with monkeypatch.context() as m: + m.setattr("builtins.print", Mock()) + importlib.reload(sys.modules["pythonrc"]) + print.assert_any_call("Ctrl click to launch VS Code Native REPL") diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index 1e5d28d778dc..18ab501f241b 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -92,6 +92,7 @@ export namespace AttachProcess { export namespace Repl { export const disableSmartSend = l10n.t('Disable Smart Send'); + export const launchNativeRepl = l10n.t('Launch VS Code Native REPL'); } export namespace Pylance { export const remindMeLater = l10n.t('Remind me later'); diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts index 80825018c4a3..fc63a189f2ff 100644 --- a/src/client/common/vscodeApis/windowApis.ts +++ b/src/client/common/vscodeApis/windowApis.ts @@ -21,6 +21,7 @@ import { TerminalShellExecutionStartEvent, LogOutputChannel, OutputChannel, + TerminalLinkProvider, } from 'vscode'; import { createDeferred, Deferred } from '../utils/async'; import { Resource } from '../types'; @@ -258,3 +259,7 @@ export function createOutputChannel(name: string, languageId?: string): OutputCh export function createLogOutputChannel(name: string, options: { log: true }): LogOutputChannel { return window.createOutputChannel(name, options); } + +export function registerTerminalLinkProvider(provider: TerminalLinkProvider): Disposable { + return window.registerTerminalLinkProvider(provider); +} diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 38f2d6a56277..4a1acca62da5 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -55,6 +55,7 @@ import { registerReplCommands, registerReplExecuteOnEnter, registerStartNativeRe import { registerTriggerForTerminalREPL } from './terminals/codeExecution/terminalReplWatcher'; import { registerPythonStartup } from './terminals/pythonStartup'; import { registerPixiFeatures } from './pythonEnvironments/common/environmentManagers/pixi'; +import { registerCustomTerminalLinkProvider } from './terminals/pythonStartupLinkProvider'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -115,6 +116,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components): registerStartNativeReplCommand(ext.disposables, interpreterService); registerReplCommands(ext.disposables, interpreterService, executionHelper, commandManager); registerReplExecuteOnEnter(ext.disposables, interpreterService, commandManager); + registerCustomTerminalLinkProvider(ext.disposables); } /// ////////////////////////// diff --git a/src/client/terminals/pythonStartupLinkProvider.ts b/src/client/terminals/pythonStartupLinkProvider.ts new file mode 100644 index 000000000000..00dcbfd757aa --- /dev/null +++ b/src/client/terminals/pythonStartupLinkProvider.ts @@ -0,0 +1,44 @@ +/* eslint-disable class-methods-use-this */ +import { + CancellationToken, + Disposable, + ProviderResult, + TerminalLink, + TerminalLinkContext, + TerminalLinkProvider, +} from 'vscode'; +import { executeCommand } from '../common/vscodeApis/commandApis'; +import { registerTerminalLinkProvider } from '../common/vscodeApis/windowApis'; +import { Repl } from '../common/utils/localize'; + +interface CustomTerminalLink extends TerminalLink { + command: string; +} + +export class CustomTerminalLinkProvider implements TerminalLinkProvider { + provideTerminalLinks( + context: TerminalLinkContext, + _token: CancellationToken, + ): ProviderResult { + const links: CustomTerminalLink[] = []; + const expectedNativeLink = 'VS Code Native REPL'; + + if (context.line.includes(expectedNativeLink)) { + links.push({ + startIndex: context.line.indexOf(expectedNativeLink), + length: expectedNativeLink.length, + tooltip: Repl.launchNativeRepl, + command: 'python.startNativeREPL', + }); + } + return links; + } + + async handleTerminalLink(link: CustomTerminalLink): Promise { + await executeCommand(link.command); + } +} + +export function registerCustomTerminalLinkProvider(disposables: Disposable[]): void { + disposables.push(registerTerminalLinkProvider(new CustomTerminalLinkProvider())); +} diff --git a/src/test/terminals/shellIntegration/pythonStartup.test.ts b/src/test/terminals/shellIntegration/pythonStartup.test.ts index 5d25c2563cf9..35674e188cd9 100644 --- a/src/test/terminals/shellIntegration/pythonStartup.test.ts +++ b/src/test/terminals/shellIntegration/pythonStartup.test.ts @@ -3,10 +3,23 @@ import * as sinon from 'sinon'; import * as TypeMoq from 'typemoq'; -import { GlobalEnvironmentVariableCollection, Uri, WorkspaceConfiguration } from 'vscode'; +import { + GlobalEnvironmentVariableCollection, + Uri, + WorkspaceConfiguration, + Disposable, + CancellationToken, + TerminalLinkContext, + Terminal, + EventEmitter, +} from 'vscode'; +import { assert } from 'chai'; import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; import { registerPythonStartup } from '../../../client/terminals/pythonStartup'; import { IExtensionContext } from '../../../client/common/types'; +import * as pythonStartupLinkProvider from '../../../client/terminals/pythonStartupLinkProvider'; +import { CustomTerminalLinkProvider } from '../../../client/terminals/pythonStartupLinkProvider'; +import { Repl } from '../../../client/common/utils/localize'; suite('Terminal - Shell Integration with PYTHONSTARTUP', () => { let getConfigurationStub: sinon.SinonStub; @@ -20,7 +33,6 @@ suite('Terminal - Shell Integration with PYTHONSTARTUP', () => { setup(() => { context = TypeMoq.Mock.ofType(); globalEnvironmentVariableCollection = TypeMoq.Mock.ofType(); - // Question: Why do we have to set up environmentVariableCollection and globalEnvironmentVariableCollection in this flip-flop way? // Reference: /vscode-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts context.setup((c) => c.environmentVariableCollection).returns(() => globalEnvironmentVariableCollection.object); @@ -122,4 +134,64 @@ suite('Terminal - Shell Integration with PYTHONSTARTUP', () => { globalEnvironmentVariableCollection.verify((c) => c.delete('PYTHONSTARTUP'), TypeMoq.Times.once()); }); + + test('Ensure registering terminal link calls registerTerminalLinkProvider', async () => { + const registerTerminalLinkProviderStub = sinon.stub( + pythonStartupLinkProvider, + 'registerCustomTerminalLinkProvider', + ); + const disposableArray: Disposable[] = []; + pythonStartupLinkProvider.registerCustomTerminalLinkProvider(disposableArray); + + sinon.assert.calledOnce(registerTerminalLinkProviderStub); + sinon.assert.calledWith(registerTerminalLinkProviderStub, disposableArray); + + registerTerminalLinkProviderStub.restore(); + }); + + test('Verify provideTerminalLinks returns links when context.line contains expectedNativeLink', () => { + const provider = new CustomTerminalLinkProvider(); + const context: TerminalLinkContext = { + line: 'Some random string with VS Code Native REPL in it', + terminal: {} as Terminal, + }; + const token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: new EventEmitter().event, + }; + + const links = provider.provideTerminalLinks(context, token); + + assert.isNotNull(links, 'Expected links to be not undefined'); + assert.isArray(links, 'Expected links to be an array'); + assert.isNotEmpty(links, 'Expected links to be not empty'); + + if (Array.isArray(links)) { + assert.equal(links[0].command, 'python.startNativeREPL', 'Expected command to be python.startNativeREPL'); + assert.equal( + links[0].startIndex, + context.line.indexOf('VS Code Native REPL'), + 'Expected startIndex to be 0', + ); + assert.equal(links[0].length, 'VS Code Native REPL'.length, 'Expected length to be 16'); + assert.equal(links[0].tooltip, Repl.launchNativeRepl, 'Expected tooltip to be Launch VS Code Native REPL'); + } + }); + + test('Verify provideTerminalLinks returns no links when context.line does not contain expectedNativeLink', () => { + const provider = new CustomTerminalLinkProvider(); + const context: TerminalLinkContext = { + line: 'Some random string without the expected link', + terminal: {} as Terminal, + }; + const token: CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: new EventEmitter().event, + }; + + const links = provider.provideTerminalLinks(context, token); + + assert.isArray(links, 'Expected links to be an array'); + assert.isEmpty(links, 'Expected links to be empty'); + }); });