diff --git a/examples/playwright/src/tests/theia-getting-started.test.ts b/examples/playwright/src/tests/theia-getting-started.test.ts new file mode 100644 index 0000000000000..e938e71beb265 --- /dev/null +++ b/examples/playwright/src/tests/theia-getting-started.test.ts @@ -0,0 +1,50 @@ +// ***************************************************************************** +// Copyright (C) 2024 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { expect, test } from '@playwright/test'; +import { TheiaApp } from '../theia-app'; +import { TheiaAppLoader } from '../theia-app-loader'; +import { TheiaExplorerView } from '../theia-explorer-view'; + +/** + * Test the Theia welcome page from the getting-started package. + */ +test.describe('Theia Welcome Page', () => { + let app: TheiaApp; + + test.beforeAll(async ({ playwright, browser }) => { + app = await TheiaAppLoader.load({ playwright, browser }); + await app.isMainContentPanelVisible(); + }); + + test.afterAll(async () => { + await app.page.close(); + }); + + test('New File... entry should create a new file.', async () => { + await app.page.getByRole('button', { name: 'New File...' }).click(); + const quickPicker = app.page.getByPlaceholder('Select File Type or Enter'); + await quickPicker.fill('testfile.txt'); + await quickPicker.press('Enter'); + await app.page.getByRole('button', { name: 'Create File' }).click(); + + // check file in workspace exists + const explorer = await app.openView(TheiaExplorerView); + await explorer.refresh(); + await explorer.waitForVisibleFileNodes(); + expect(await explorer.existsFileNode('testfile.txt')).toBe(true); + }); +}); 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..dc7d940e005c2 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -280,6 +280,15 @@ export namespace CommonCommands { category: VIEW_CATEGORY, label: 'Toggle Menu Bar' }); + /** + * Command Parameters: + * - `fileName`: string + * - `directory`: URI + */ + 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 +1433,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 +1456,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 and parent dir as arguments + return this.commandRegistry.executeCommand(CommonCommands.NEW_FILE.id, createNewFileItem.value, parent); + } + } + } : 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..78ba910633819 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-contribution.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-contribution.ts @@ -14,27 +14,36 @@ // 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 { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider'; +import { FileChangeType, FileChangesEvent, FileOperation } 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 +87,15 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri @inject(FileService) protected readonly fileService: FileService; + @inject(FileDialogService) + protected readonly fileDialogService: FileDialogService; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(UserWorkingDirectoryProvider) + protected readonly workingDirectory: UserWorkingDirectoryProvider; + protected onDidChangeEditorFileEmitter = new Emitter<{ editor: NavigatableWidget, type: FileChangeType }>(); readonly onDidChangeEditorFile = this.onDidChangeEditorFileEmitter.event; @@ -134,6 +152,11 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri } } }); + commands.registerCommand(CommonCommands.NEW_FILE, { + execute: (...args: unknown[]) => { + this.handleNewFileCommand(args); + } + }); } protected canUpload({ fileStat }: FileSelection): boolean { @@ -155,6 +178,26 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri } } + /** + * Opens a save dialog to create a new file. + * + * @param args The first argument is the name of the new file. The second argument is the parent directory URI. + */ + protected async handleNewFileCommand(args: unknown[]): Promise { + const fileName = (args !== undefined && typeof args[0] === 'string') ? args[0] : undefined; + const title = nls.localizeByDefault('Create File'); + const props: SaveFileDialogProps = { title, saveLabel: title, inputValue: fileName }; + + const dirUri = (args[1] instanceof URI) ? args[1] : await this.workingDirectory.getUserWorkingDir(); + const directory = await this.fileService.resolve(dirUri); + + const filePath = await this.fileDialogService.showSaveDialog(props, directory.isDirectory ? directory : undefined); + if (filePath) { + const file = await this.fileService.createFile(filePath); + open(this.openerService, file.resource); + } + } + protected getSelection(...args: unknown[]): FileSelection | undefined { const { selection } = this.selectionService; return this.toSelection(args[0]) ?? (Array.isArray(selection) ? selection.find(FileSelection.is) : this.toSelection(selection)); diff --git a/packages/getting-started/src/browser/getting-started-widget.tsx b/packages/getting-started/src/browser/getting-started-widget.tsx index 22fa83fe8d769..c40b7c95ce93c 100644 --- a/packages/getting-started/src/browser/getting-started-widget.tsx +++ b/packages/getting-started/src/browser/getting-started-widget.tsx @@ -135,7 +135,7 @@ export class GettingStartedWidget extends ReactWidget {
- {this.renderOpen()} + {this.renderStart()}
@@ -176,12 +176,22 @@ export class GettingStartedWidget extends ReactWidget { } /** - * Render the `open` section. - * Displays a collection of `open` commands. + * Render the `Start` section. + * Displays a collection of "start-to-work" related commands like `open` commands and some other. */ - protected renderOpen(): React.ReactNode { + protected renderStart(): 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')}

+

{nls.localizeByDefault('Start')}

+ {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. */