Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide "New File" default implementation (#13303) #13344

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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