diff --git a/news/1 Enhancements/18376.md b/news/1 Enhancements/18376.md new file mode 100644 index 000000000000..394342d70349 --- /dev/null +++ b/news/1 Enhancements/18376.md @@ -0,0 +1 @@ +Implement a "New Python File" command \ No newline at end of file diff --git a/package.json b/package.json index fea002930776..ffa4bf9bfdd3 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "onDebugInitialConfigurations", "onLanguage:python", "onDebugResolve:python", + "onCommand:python.createNewFile", "onCommand:python.execInTerminal", "onCommand:python.debugInTerminal", "onCommand:python.sortImports", @@ -322,6 +323,13 @@ } ], "commands": [ + { + "title": "%python.command.python.createNewFile.title%", + "shortTitle": "%python.menu.createNewFile.title%", + "category": "Python", + "command": "python.createNewFile", + "when": "config.python.createNewFileEnabled" + }, { "category": "Python", "command": "python.analysis.restartLanguageServer", @@ -503,6 +511,15 @@ "scope": "machine", "type": "string" }, + "python.createNewFileEnabled": { + "default": "false", + "description": "Enable the `Python: New Python File` command.", + "scope": "machine", + "type": "boolean", + "tags": [ + "experimental" + ] + }, "python.defaultInterpreterPath": { "default": "python", "description": "Path to default Python to use when extension loads up for the first time, no longer used once an interpreter is selected for the workspace. See https://aka.ms/AAfekmf to understand when this is used.", @@ -1863,6 +1880,13 @@ "when": "resourceLangId == python && !virtualWorkspace && shellExecutionSupported" } ], + "file/newFile": [ + { + "command": "python.createNewFile", + "category": "file", + "when": "config.python.createNewFileEnabled" + } + ], "view/title": [ { "command": "python.refreshTests", diff --git a/package.nls.json b/package.nls.json index 775598c7fcb5..1d737a94770b 100644 --- a/package.nls.json +++ b/package.nls.json @@ -2,6 +2,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.createNewFile.title": "New Python File", "python.command.python.execInTerminal.title": "Run Python File in Terminal", "python.command.python.debugInTerminal.title": "Debug Python File", "python.command.python.execInTerminalIcon.title": "Run Python File", @@ -29,6 +30,7 @@ "python.command.python.analysis.restartLanguageServer.title": "Restart Language Server", "python.command.python.launchTensorBoard.title": "Launch TensorBoard", "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", + "python.menu.createNewFile.title": "Python File", "python.snippet.launch.standard.label": "Python: Current File", "python.snippet.launch.module.label": "Python: Module", "python.snippet.launch.module.default": "enter-your-module-name", diff --git a/src/client/common/application/applicationShell.ts b/src/client/common/application/applicationShell.ts index 1f243682f18c..62696c34524c 100644 --- a/src/client/common/application/applicationShell.ts +++ b/src/client/common/application/applicationShell.ts @@ -26,9 +26,12 @@ import { SaveDialogOptions, StatusBarAlignment, StatusBarItem, + TextDocument, + TextEditor, TreeView, TreeViewOptions, Uri, + ViewColumn, window, WindowState, WorkspaceFolder, @@ -100,6 +103,14 @@ export class ApplicationShell implements IApplicationShell { public showInputBox(options?: InputBoxOptions, token?: CancellationToken): Thenable { return window.showInputBox(options, token); } + public showTextDocument( + document: TextDocument, + column?: ViewColumn, + preserveFocus?: boolean, + ): Thenable { + return window.showTextDocument(document, column, preserveFocus); + } + public openUrl(url: string): void { env.openExternal(Uri.parse(url)); } diff --git a/src/client/common/application/commands.ts b/src/client/common/application/commands.ts index eb4e827d42bb..aaf1a710a947 100644 --- a/src/client/common/application/commands.ts +++ b/src/client/common/application/commands.ts @@ -42,6 +42,7 @@ interface ICommandNameWithoutArgumentTypeMapping { [Commands.PickLocalProcess]: []; [Commands.ClearStorage]: []; [Commands.ReportIssue]: []; + [Commands.CreateNewFile]: []; [Commands.RefreshTensorBoard]: []; [LSCommands.RestartLS]: []; } diff --git a/src/client/common/application/commands/createFileCommand.ts b/src/client/common/application/commands/createFileCommand.ts new file mode 100644 index 000000000000..509f1a470ce1 --- /dev/null +++ b/src/client/common/application/commands/createFileCommand.ts @@ -0,0 +1,30 @@ +import { injectable, inject } from 'inversify'; +import { IExtensionSingleActivationService } from '../../../activation/types'; +import { Commands } from '../../constants'; +import { IApplicationShell, ICommandManager, IWorkspaceService } from '../types'; +import { sendTelemetryEvent } from '../../../telemetry'; +import { EventName } from '../../../telemetry/constants'; + +@injectable() +export class CreatePythonFileCommandHandler implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { untrustedWorkspace: true, virtualWorkspace: true }; + + constructor( + @inject(ICommandManager) private readonly commandManager: ICommandManager, + @inject(IWorkspaceService) private readonly workspaceService: IWorkspaceService, + @inject(IApplicationShell) private readonly appShell: IApplicationShell, + ) {} + + public async activate(): Promise { + if (!this.workspaceService.getConfiguration('python').get('createNewFileEnabled')) { + return; + } + this.commandManager.registerCommand(Commands.CreateNewFile, this.createPythonFile, this); + } + + public async createPythonFile(): Promise { + const newFile = await this.workspaceService.openTextDocument({ language: 'python' }); + this.appShell.showTextDocument(newFile); + sendTelemetryEvent(EventName.CREATE_NEW_FILE_COMMAND); + } +} diff --git a/src/client/common/application/types.ts b/src/client/common/application/types.ts index c8335e288447..627155f82ea1 100644 --- a/src/client/common/application/types.ts +++ b/src/client/common/application/types.ts @@ -273,6 +273,19 @@ export interface IApplicationShell { */ showInputBox(options?: InputBoxOptions, token?: CancellationToken): Thenable; + /** + * Show the given document in a text editor. A {@link ViewColumn column} can be provided + * to control where the editor is being shown. Might change the {@link window.activeTextEditor active editor}. + * + * @param document A text document to be shown. + * @param column A view column in which the {@link TextEditor editor} should be shown. The default is the {@link ViewColumn.Active active}, other values + * are adjusted to be `Min(column, columnCount + 1)`, the {@link ViewColumn.Active active}-column is not adjusted. Use {@linkcode ViewColumn.Beside} + * to open the editor to the side of the currently active one. + * @param preserveFocus When `true` the editor will not take focus. + * @return A promise that resolves to an {@link TextEditor editor}. + */ + showTextDocument(document: TextDocument, column?: ViewColumn, preserveFocus?: boolean): Thenable; + /** * Creates a [QuickPick](#QuickPick) to let the user pick an item from a list * of items of type T. @@ -833,6 +846,16 @@ export interface IWorkspaceService { * @return The full configuration or a subset. */ getConfiguration(section?: string, resource?: Uri): WorkspaceConfiguration; + + /** + * Opens an untitled text document. The editor will prompt the user for a file + * path when the document is to be saved. The `options` parameter allows to + * specify the *language* and/or the *content* of the document. + * + * @param options Options to control how the document will be created. + * @return A promise that resolves to a {@link TextDocument document}. + */ + openTextDocument(options?: { language?: string; content?: string }): Thenable; } export const ITerminalManager = Symbol('ITerminalManager'); diff --git a/src/client/common/application/workspace.ts b/src/client/common/application/workspace.ts index c830401decfb..5144bfaf748b 100644 --- a/src/client/common/application/workspace.ts +++ b/src/client/common/application/workspace.ts @@ -9,6 +9,7 @@ import { Event, FileSystemWatcher, GlobPattern, + TextDocument, Uri, workspace, WorkspaceConfiguration, @@ -97,6 +98,10 @@ export class WorkspaceService implements IWorkspaceService { return workspace.onDidGrantWorkspaceTrust; } + public openTextDocument(options?: { language?: string; content?: string }): Thenable { + return workspace.openTextDocument(options); + } + private get searchExcludes() { const searchExcludes = this.getConfiguration('search.exclude'); const enabledSearchExcludes = Object.keys(searchExcludes).filter((key) => searchExcludes.get(key) === true); diff --git a/src/client/common/constants.ts b/src/client/common/constants.ts index 6cd283daab99..2fa39e0219b6 100644 --- a/src/client/common/constants.ts +++ b/src/client/common/constants.ts @@ -49,6 +49,7 @@ export namespace Commands { export const ViewOutput = 'python.viewOutput'; export const Start_REPL = 'python.startREPL'; export const Create_Terminal = 'python.createTerminal'; + export const CreateNewFile = 'python.createNewFile'; export const Set_Linter = 'python.setLinter'; export const Enable_Linter = 'python.enableLinting'; export const Run_Linter = 'python.runLinting'; diff --git a/src/client/common/serviceRegistry.ts b/src/client/common/serviceRegistry.ts index 674597928db0..d78642a56d28 100644 --- a/src/client/common/serviceRegistry.ts +++ b/src/client/common/serviceRegistry.ts @@ -31,6 +31,7 @@ import { ClipboardService } from './application/clipboard'; import { CommandManager } from './application/commandManager'; import { ReloadVSCodeCommandHandler } from './application/commands/reloadCommand'; import { ReportIssueCommandHandler } from './application/commands/reportIssueCommand'; +import { CreatePythonFileCommandHandler } from './application/commands/createFileCommand'; import { DebugService } from './application/debugService'; import { DebugSessionTelemetry } from './application/debugSessionTelemetry'; import { DocumentManager } from './application/documentManager'; @@ -198,6 +199,10 @@ export function registerTypes(serviceManager: IServiceManager): void { IExtensionSingleActivationService, ReportIssueCommandHandler, ); + serviceManager.addSingleton( + IExtensionSingleActivationService, + CreatePythonFileCommandHandler, + ); serviceManager.addSingleton(IExtensionChannelService, ExtensionChannelService); serviceManager.addSingleton( IExtensionChannelRule, diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index 18979b117c8a..0845613f45f9 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -94,6 +94,7 @@ export enum EventName { SELECT_LINTER = 'LINTING.SELECT', USE_REPORT_ISSUE_COMMAND = 'USE_REPORT_ISSUE_COMMAND', + CREATE_NEW_FILE_COMMAND = 'CREATE_NEW_FILE_COMMAND', LINTER_NOT_INSTALLED_PROMPT = 'LINTER_NOT_INSTALLED_PROMPT', HASHED_PACKAGE_NAME = 'HASHED_PACKAGE_NAME', diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 21b615d19434..9d0a0bf36295 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -1592,6 +1592,13 @@ export interface IEventNamePropertyMapping { "use_report_issue_command" : { } */ [EventName.USE_REPORT_ISSUE_COMMAND]: unknown; + /** + * Telemetry event sent when the New Python File command is executed. + */ + /* __GDPR__ + "create_new_file_command" : { } + */ + [EventName.CREATE_NEW_FILE_COMMAND]: unknown; /** * Telemetry event sent once on session start with details on which experiments are opted into and opted out from. */ diff --git a/src/test/common/application/commands/createNewFileCommand.unit.test.ts b/src/test/common/application/commands/createNewFileCommand.unit.test.ts new file mode 100644 index 000000000000..ce090bfe637d --- /dev/null +++ b/src/test/common/application/commands/createNewFileCommand.unit.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { TextDocument } from 'vscode'; +import { Commands } from '../../../../client/common/constants'; +import { CommandManager } from '../../../../client/common/application/commandManager'; +import { CreatePythonFileCommandHandler } from '../../../../client/common/application/commands/createFileCommand'; +import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../../client/common/application/types'; +import { MockWorkspaceConfiguration } from '../../../mocks/mockWorkspaceConfig'; +import { WorkspaceService } from '../../../../client/common/application/workspace'; +import { ApplicationShell } from '../../../../client/common/application/applicationShell'; + +suite('Create New Python File Commmand', () => { + let createNewFileCommandHandler: CreatePythonFileCommandHandler; + let cmdManager: ICommandManager; + let workspaceService: IWorkspaceService; + let appShell: IApplicationShell; + + setup(async () => { + cmdManager = mock(CommandManager); + workspaceService = mock(WorkspaceService); + appShell = mock(ApplicationShell); + + createNewFileCommandHandler = new CreatePythonFileCommandHandler( + instance(cmdManager), + instance(workspaceService), + instance(appShell), + ); + when(workspaceService.getConfiguration('python')).thenReturn( + new MockWorkspaceConfiguration({ + createNewFileEnabled: true, + }), + ); + when(workspaceService.openTextDocument(deepEqual({ language: 'python' }))).thenReturn( + Promise.resolve(({} as unknown) as TextDocument), + ); + await createNewFileCommandHandler.activate(); + }); + + test('Create Python file command is registered', async () => { + verify(cmdManager.registerCommand(Commands.CreateNewFile, anything(), anything())).once(); + }); + test('Create a Python file if command is executed', async () => { + await createNewFileCommandHandler.createPythonFile(); + verify(workspaceService.openTextDocument(deepEqual({ language: 'python' }))).once(); + verify(appShell.showTextDocument(anything())).once(); + }); +});