Skip to content

Commit

Permalink
Provide "New File" default implementation (#13303) (#13344)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhuebner committed Mar 21, 2024
1 parent cffeeac commit cf63b20
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 20 deletions.
50 changes: 50 additions & 0 deletions 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);
});
});
21 changes: 21 additions & 0 deletions examples/playwright/src/tests/theia-main-menu.test.ts
Expand Up @@ -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', () => {

Expand Down Expand Up @@ -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);
});
});
45 changes: 44 additions & 1 deletion packages/core/src/browser/common-frontend-contribution.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
});
}

Expand Down
71 changes: 57 additions & 14 deletions packages/filesystem/src/browser/filesystem-frontend-contribution.ts
Expand Up @@ -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 {

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand All @@ -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<void> {
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));
Expand Down
31 changes: 26 additions & 5 deletions packages/getting-started/src/browser/getting-started-widget.tsx
Expand Up @@ -135,7 +135,7 @@ export class GettingStartedWidget extends ReactWidget {
<hr className='gs-hr' />
<div className='flex-grid'>
<div className='col'>
{this.renderOpen()}
{this.renderStart()}
</div>
</div>
<div className='flex-grid'>
Expand Down Expand Up @@ -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 = <div className='gs-action-container'>
<a
role={'button'}
tabIndex={0}
onClick={this.doCreateFile}
onKeyDown={this.doCreateFileEnter}>
{CommonCommands.NEW_UNTITLED_FILE.label ?? nls.localizeByDefault('New File...')}
</a>
</div>;

const open = requireSingleOpen && <div className='gs-action-container'>
<a
role={'button'}
Expand Down Expand Up @@ -223,7 +233,8 @@ export class GettingStartedWidget extends ReactWidget {
);

return <div className='gs-section'>
<h3 className='gs-section-header'><i className={codicon('folder-opened')}></i>{nls.localizeByDefault('Open')}</h3>
<h3 className='gs-section-header'><i className={codicon('folder-opened')}></i>{nls.localizeByDefault('Start')}</h3>
{createFile}
{open}
{openFile}
{openFolder}
Expand Down Expand Up @@ -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.
*/
Expand Down

0 comments on commit cf63b20

Please sign in to comment.