diff --git a/examples/playwright/src/tests/theia-main-menu.test.ts b/examples/playwright/src/tests/theia-main-menu.test.ts index f00fcb6727270..a8807535f533d 100644 --- a/examples/playwright/src/tests/theia-main-menu.test.ts +++ b/examples/playwright/src/tests/theia-main-menu.test.ts @@ -20,6 +20,7 @@ import { TheiaAppLoader } from '../theia-app-loader'; import { TheiaAboutDialog } from '../theia-about-dialog'; import { TheiaMenuBar } from '../theia-main-menu'; import { OSUtil } from '../util'; +import { TheiaExplorerView } from '../theia-explorer-view'; test.describe('Theia Main Menu', () => { @@ -109,4 +110,24 @@ test.describe('Theia Main Menu', () => { expect(await fileDialog.isVisible()).toBe(false); }); + test('Create file via New File menu and cancel', async () => { + const openFileEntry = 'New File...'; + await (await menuBar.openMenu('File')).clickMenuItem(openFileEntry); + const quickPick = app.page.getByPlaceholder('Select File Type or Enter'); + // type file name and press enter + await quickPick.fill('test.txt'); + await quickPick.press('Enter'); + + // check file dialog is opened and accept with "Create File" button + const fileDialog = await app.page.waitForSelector('div[class="dialogBlock"]'); + expect(await fileDialog.isVisible()).toBe(true); + await app.page.locator('#theia-dialog-shell').getByRole('button', { name: 'Create File' }).click(); + expect(await fileDialog.isVisible()).toBe(false); + + // check file in workspace exists + const explorer = await app.openView(TheiaExplorerView); + await explorer.refresh(); + await explorer.waitForVisibleFileNodes(); + expect(await explorer.existsFileNode('test.txt')).toBe(true); + }); }); diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 99376fa1467a5..875e12ae67761 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -280,6 +280,10 @@ export namespace CommonCommands { category: VIEW_CATEGORY, label: 'Toggle Menu Bar' }); + export const NEW_FILE = Command.toDefaultLocalizedCommand({ + id: 'workbench.action.files.newFile', + category: FILE_CATEGORY + }); export const NEW_UNTITLED_TEXT_FILE = Command.toDefaultLocalizedCommand({ id: 'workbench.action.files.newUntitledTextFile', category: FILE_CATEGORY, @@ -1424,6 +1428,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi const items: QuickPickItemOrSeparator[] = [ { label: nls.localizeByDefault('New Text File'), + description: nls.localizeByDefault('Built-in'), execute: async () => this.commandRegistry.executeCommand(CommonCommands.NEW_UNTITLED_TEXT_FILE.id) }, ...newFileContributions.children @@ -1446,10 +1451,43 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }) ]; + + const CREATE_NEW_FILE_ITEM_ID = 'create-new-file'; + const hasNewFileHandler = this.commandRegistry.getActiveHandler(CommonCommands.NEW_FILE.id) !== undefined; + // Create a "Create New File" item only if there is a NEW_FILE command handler. + const createNewFileItem: QuickPickItem & { value?: string } | undefined = hasNewFileHandler ? { + id: CREATE_NEW_FILE_ITEM_ID, + label: nls.localizeByDefault('Create New File ({0})'), + description: nls.localizeByDefault('Built-in'), + execute: async () => { + if (createNewFileItem?.value) { + const parent = await this.workingDirProvider.getUserWorkingDir(); + // Exec NEW_FILE command with the file name as additional argument + return this.commandRegistry.executeCommand(CommonCommands.NEW_FILE.id, parent, createNewFileItem.value); + } + } + } : undefined; + this.quickInputService.showQuickPick(items, { title: nls.localizeByDefault('New File...'), placeholder: nls.localizeByDefault('Select File Type or Enter File Name...'), - canSelectMany: false + canSelectMany: false, + onDidChangeValue: picker => { + if (createNewFileItem === undefined) { + return; + } + // Dynamically show or hide the "Create New File" item based on the input value. + if (picker.value) { + createNewFileItem.alwaysShow = true; + createNewFileItem.value = picker.value; + createNewFileItem.label = nls.localizeByDefault('Create New File ({0})', picker.value); + picker.items = [...items, createNewFileItem]; + } else { + createNewFileItem.alwaysShow = false; + createNewFileItem.value = undefined; + picker.items = items.filter(item => item !== createNewFileItem); + } + } }); } diff --git a/packages/filesystem/src/browser/filesystem-frontend-contribution.ts b/packages/filesystem/src/browser/filesystem-frontend-contribution.ts index 13917733ec2c5..997126ce6ad8d 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-contribution.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-contribution.ts @@ -14,27 +14,35 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable, inject } from '@theia/core/shared/inversify'; -import URI from '@theia/core/lib/common/uri'; -import { environment } from '@theia/core/shared/@theia/application-package/lib/environment'; -import { MaybePromise, SelectionService, isCancelled, Emitter } from '@theia/core/lib/common'; -import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; +import { nls } from '@theia/core'; import { - FrontendApplicationContribution, ApplicationShell, - NavigatableWidget, NavigatableWidgetOptions, - Saveable, WidgetManager, StatefulWidget, FrontendApplication, ExpandableTreeNode, - CorePreferences, + ApplicationShell, CommonCommands, + CorePreferences, + ExpandableTreeNode, + FrontendApplication, + FrontendApplicationContribution, + NavigatableWidget, NavigatableWidgetOptions, + OpenerService, + Saveable, + StatefulWidget, + WidgetManager, + open } from '@theia/core/lib/browser'; import { MimeService } from '@theia/core/lib/browser/mime-service'; import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; -import { FileSystemPreferences } from './filesystem-preferences'; +import { Emitter, MaybePromise, SelectionService, isCancelled } from '@theia/core/lib/common'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import URI from '@theia/core/lib/common/uri'; +import { environment } from '@theia/core/shared/@theia/application-package/lib/environment'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { FileChangeType, FileChangesEvent, FileOperation, FileStat } from '../common/files'; +import { FileDialogService, SaveFileDialogProps } from './file-dialog'; import { FileSelection } from './file-selection'; -import { FileUploadService, FileUploadResult } from './file-upload-service'; import { FileService, UserFileOperationEvent } from './file-service'; -import { FileChangesEvent, FileChangeType, FileOperation } from '../common/files'; -import { Deferred } from '@theia/core/lib/common/promise-util'; -import { nls } from '@theia/core'; +import { FileUploadResult, FileUploadService } from './file-upload-service'; +import { FileSystemPreferences } from './filesystem-preferences'; export namespace FileSystemCommands { @@ -78,6 +86,12 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri @inject(FileService) protected readonly fileService: FileService; + @inject(FileDialogService) + protected readonly fileDialogService: FileDialogService; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + protected onDidChangeEditorFileEmitter = new Emitter<{ editor: NavigatableWidget, type: FileChangeType }>(); readonly onDidChangeEditorFile = this.onDidChangeEditorFileEmitter.event; @@ -134,6 +148,22 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri } } }); + commands.registerCommand(CommonCommands.NEW_FILE, { + execute: async (...args: unknown[]) => { + let newFileName = undefined; + if (args !== undefined && typeof args[1] === 'string') { + newFileName = args[1]; + } + const title = nls.localizeByDefault('Create File'); + const props: SaveFileDialogProps = { title, saveLabel: title, inputValue: newFileName }; + const selectedFile = this.getSelection(...args)?.fileStat ?? await this.getFolderFromArgs(...args); + const filePath = await this.fileDialogService.showSaveDialog(props, selectedFile); + if (filePath) { + const file = await this.fileService.createFile(filePath); + open(this.openerService, file.resource); + } + } + }); } protected canUpload({ fileStat }: FileSelection): boolean { @@ -160,6 +190,19 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri return this.toSelection(args[0]) ?? (Array.isArray(selection) ? selection.find(FileSelection.is) : this.toSelection(selection)); }; + /** + * Retrieves the folder from the arguments. + * @param args The arguments passed to a command. + * @returns A promise that resolves to a FileStat representing the folder, or undefined if the folder cannot be found. + */ + protected async getFolderFromArgs(...args: unknown[]): Promise { + if (args[0] instanceof URI) { + const fileStat = await this.fileService.resolve(args[0] as URI); + return fileStat.isDirectory ? fileStat : undefined; + } + return undefined; + }; + protected toSelection(arg: unknown): FileSelection | undefined { return FileSelection.is(arg) ? arg : undefined; } diff --git a/packages/getting-started/src/browser/getting-started-widget.tsx b/packages/getting-started/src/browser/getting-started-widget.tsx index 22fa83fe8d769..80df0f264a4b5 100644 --- a/packages/getting-started/src/browser/getting-started-widget.tsx +++ b/packages/getting-started/src/browser/getting-started-widget.tsx @@ -182,6 +182,16 @@ export class GettingStartedWidget extends ReactWidget { protected renderOpen(): React.ReactNode { const requireSingleOpen = isOSX || !environment.electron.is(); + const createFile =
+ + {CommonCommands.NEW_UNTITLED_FILE.label ?? nls.localizeByDefault('New File...')} + +
; + const open = requireSingleOpen &&

{nls.localizeByDefault('Open')}

+ {createFile} {open} {openFile} {openFolder} @@ -392,6 +403,16 @@ export class GettingStartedWidget extends ReactWidget { return paths; } + /** + * Trigger the create file command. + */ + protected doCreateFile = () => this.commandRegistry.executeCommand(CommonCommands.NEW_UNTITLED_FILE.id); + protected doCreateFileEnter = (e: React.KeyboardEvent) => { + if (this.isEnterKey(e)) { + this.doCreateFile(); + } + }; + /** * Trigger the open command. */ diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 2e7625f434485..9fcbe822e3612 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -88,11 +88,13 @@ export namespace WorkspaceCommands { category: WORKSPACE_CATEGORY, label: 'Close Workspace' }); + export const NEW_FILE = Command.toDefaultLocalizedCommand({ id: 'file.newFile', category: FILE_CATEGORY, label: 'New File...' }); + export const NEW_FOLDER = Command.toDefaultLocalizedCommand({ id: 'file.newFolder', category: FILE_CATEGORY, @@ -226,32 +228,34 @@ export class WorkspaceCommandContribution implements CommandContribution { registerCommands(registry: CommandRegistry): void { this.registerOpenWith(registry); registry.registerCommand(WorkspaceCommands.NEW_FILE, this.newWorkspaceRootUriAwareCommandHandler({ - execute: uri => this.getDirectory(uri).then(parent => { - if (parent) { - const parentUri = parent.resource; - const { fileName, fileExtension } = this.getDefaultFileConfig(); - const targetUri = parentUri.resolve(fileName + fileExtension); - const vacantChildUri = FileSystemUtils.generateUniqueResourceURI(parent, targetUri, false); - - const dialog = new WorkspaceInputDialog({ - title: nls.localizeByDefault('New File...'), - maxWidth: 400, - parentUri: parentUri, - initialValue: vacantChildUri.path.base, - placeholder: nls.localize('theia/workspace/newFilePlaceholder', 'File Name'), - validate: name => this.validateFileName(name, parent, true) - }, this.labelProvider); - - dialog.open().then(async name => { - if (name) { - const fileUri = parentUri.resolve(name); - await this.fileService.create(fileUri); - this.fireCreateNewFile({ parent: parentUri, uri: fileUri }); - open(this.openerService, fileUri); - } - }); + execute: async uri => { + const parent = await this.getDirectory(uri); + if (parent === undefined) { + return; } - }) + const parentUri = parent.resource; + const { fileName, fileExtension } = this.getDefaultFileConfig(); + const targetUri = parentUri.resolve(fileName + fileExtension); + const vacantChildUri = FileSystemUtils.generateUniqueResourceURI(parent, targetUri, false); + + // Open dialog + const dialog = new WorkspaceInputDialog({ + title: nls.localizeByDefault('New File...'), + maxWidth: 400, + parentUri: parentUri, + initialValue: vacantChildUri.path.base, + placeholder: nls.localize('theia/workspace/newFilePlaceholder', 'File Name'), + validate: name => this.validateFileName(name, parent, true) + }, this.labelProvider); + + const newFileName = await dialog.open(); + if (newFileName !== undefined) { + const fileUri = parentUri.resolve(newFileName); + await this.fileService.create(fileUri); + this.fireCreateNewFile({ parent: parentUri, uri: fileUri }); + open(this.openerService, fileUri); + } + } })); registry.registerCommand(WorkspaceCommands.NEW_FOLDER, this.newWorkspaceRootUriAwareCommandHandler({ execute: uri => this.getDirectory(uri).then(parent => {