Skip to content

Commit

Permalink
Add menu options to add element files: entity, relationship, diagram (#…
Browse files Browse the repository at this point in the history
…25)

- Add entries to 'File' main menu
- Add entries to explorer context menu
- Add entries to explorer toolbar

- Re-use file name as element ID
  • Loading branch information
martin-fleck-at committed Oct 19, 2023
1 parent 600cc81 commit a1d6d23
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 2 deletions.
15 changes: 13 additions & 2 deletions packages/core/src/browser/core-frontend-module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
/********************************************************************************
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import { MenuContribution } from '@theia/core';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { ContainerModule } from '@theia/core/shared/inversify';
import { FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution';
import { WorkspaceCommandContribution } from '@theia/workspace/lib/browser/workspace-commands';
import '../../style/index.css';
import { DynamicPortCleanup } from './dynamic-port-cleanup';
import { CrossModelFileNavigatorContribution, CrossModelWorkspaceContribution } from './new-element-contribution';

export default new ContainerModule(bind => {
bind(FrontendApplicationContribution).to(DynamicPortCleanup);
export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
bind(FrontendApplicationContribution).to(DynamicPortCleanup);

bind(CrossModelWorkspaceContribution).toSelf().inSingletonScope();
rebind(WorkspaceCommandContribution).toService(CrossModelWorkspaceContribution);
bind(MenuContribution).toService(CrossModelWorkspaceContribution);

bind(CrossModelFileNavigatorContribution).toSelf().inSingletonScope();
rebind(FileNavigatorContribution).toService(CrossModelFileNavigatorContribution);
});
181 changes: 181 additions & 0 deletions packages/core/src/browser/new-element-contribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/** ******************************************************************************
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import { Command, CommandContribution, CommandRegistry, MaybePromise, MenuContribution, MenuModelRegistry, URI, nls } from '@theia/core';
import { CommonMenus, DialogError, codicon, open } from '@theia/core/lib/browser';
import { TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { injectable } from '@theia/core/shared/inversify';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { FileNavigatorContribution, NavigatorContextMenu } from '@theia/navigator/lib/browser/navigator-contribution';
import { WorkspaceCommandContribution } from '@theia/workspace/lib/browser/workspace-commands';
import { WorkspaceInputDialog } from '@theia/workspace/lib/browser/workspace-input-dialog';

const NEW_ELEMENT_NAV_MENU = [...NavigatorContextMenu.NAVIGATION, '0_new'];
const NEW_ELEMENT_MAIN_MENU = [...CommonMenus.FILE, '0_new'];

interface NewElementTemplate extends Command {
label: string;
fileExtension: string;
content: string | ((name: string) => string);
}

const INITIAL_ENTITY_CONTENT = `entity:
id: "\${name}"
name: "\${name}"`;

const INITIAL_RELATIONSHIP_CONTENT = `relationship:
id: "\${name}"
parent:
child:
type: "1:1"`;

const INITIAL_DIAGRAM_CONTENT = `diagram:
id: "\${name}"
name: "\${name}"`;

const TEMPLATE_CATEGORY = 'New Element';

const NEW_ELEMENT_TEMPLATES: NewElementTemplate[] = [
{
id: 'crossbreeze.new.entity',
label: 'Entity',
fileExtension: '.entity.cm',
category: TEMPLATE_CATEGORY,
iconClass: codicon('git-commit'),
content: name => INITIAL_ENTITY_CONTENT.toString().replace(/\$\{name\}/gi, name)
},
{
id: 'crossbreeze.new.relationship',
label: 'Relationship',
fileExtension: '.relationship.cm',
category: TEMPLATE_CATEGORY,
iconClass: codicon('git-compare'),
content: name => INITIAL_RELATIONSHIP_CONTENT.toString().replace(/\$\{name\}/gi, name)
},
{
id: 'crossbreeze.new.diagram',
label: 'Diagram',
fileExtension: '.diagram.cm',
category: TEMPLATE_CATEGORY,
iconClass: codicon('type-hierarchy-sub'),
content: name => INITIAL_DIAGRAM_CONTENT.toString().replace(/\$\{name\}/gi, name)
}
];

const ID_REGEX = /^[_a-zA-Z@][\w_\-@/#]*$/; /* taken from the langium file, in newer Langium versions constants may be generated. */

@injectable()
export class CrossModelWorkspaceContribution extends WorkspaceCommandContribution implements MenuContribution, CommandContribution {
override registerCommands(commands: CommandRegistry): void {
super.registerCommands(commands);
for (const template of NEW_ELEMENT_TEMPLATES) {
commands.registerCommand(
{ ...template, label: template.label + '...' },
this.newWorkspaceRootUriAwareCommandHandler({ execute: uri => this.createNewElementFile(uri, template) })
);
}
}

registerMenus(registry: MenuModelRegistry): void {
// explorer context menu
registry.registerSubmenu(NEW_ELEMENT_NAV_MENU, TEMPLATE_CATEGORY);
for (const [id, template] of NEW_ELEMENT_TEMPLATES.entries()) {
registry.registerMenuAction(NEW_ELEMENT_NAV_MENU, {
commandId: template.id,
label: template.label + '...',
order: id.toString()
});
}

// main menu bar
registry.registerSubmenu(NEW_ELEMENT_MAIN_MENU, TEMPLATE_CATEGORY);
for (const [id, template] of NEW_ELEMENT_TEMPLATES.entries()) {
registry.registerMenuAction(NEW_ELEMENT_MAIN_MENU, {
commandId: template.id,
label: template.label + '...',
order: id.toString()
});
}
}

protected async createNewElementFile(uri: URI, template: NewElementTemplate): Promise<void> {
const parent = await this.getDirectory(uri);
if (parent) {
const parentUri = parent.resource;
const dialog = new WorkspaceInputDialog(
{
title: 'New ' + template.label + '...',
parentUri: parentUri,
initialValue: 'New' + template.label,
placeholder: 'New ' + template.label,
validate: newName => this.validateElementFileName(newName, parent, template)
},
this.labelProvider
);
const name = await dialog.open();
if (name) {
const fileName = this.applyFileExtension(name, template);
const baseFileName = this.removeFileExtension(name, template);
const elementName = baseFileName.charAt(0).toUpperCase() + baseFileName.substring(1);
const content = typeof template.content === 'string' ? template.content : template.content(elementName);
const fileUri = parentUri.resolve(fileName);
await this.fileService.create(fileUri, content);
this.fireCreateNewFile({ parent: parentUri, uri: fileUri });
open(this.openerService, fileUri);
}
}
}

protected validateElementFileName(name: string, parent: FileStat, template: NewElementTemplate): MaybePromise<DialogError> {
// default behavior for empty strings is like cancel
if (!name) {
return '';
}
// we automatically may name some part in the initial code after the given name so ensure it is an ID
if (!ID_REGEX.test(name)) {
return nls.localizeByDefault(`'${name}' is not a valid name, must match: ${ID_REGEX}.`);
}
// automatically apply file extension for better UX
return this.validateFileName(this.applyFileExtension(name, template), parent, true);
}

protected applyFileExtension(name: string, template: NewElementTemplate): string {
return name.endsWith(template.fileExtension) ? name : name + template.fileExtension;
}

protected removeFileExtension(name: string, template: NewElementTemplate): string {
return name.endsWith(template.fileExtension) ? name.slice(0, -template.fileExtension.length) : name;
}
}

@injectable()
export class CrossModelFileNavigatorContribution extends FileNavigatorContribution {
override registerCommands(registry: CommandRegistry): void {
super.registerCommands(registry);

for (const template of NEW_ELEMENT_TEMPLATES) {
registry.registerCommand(
{ ...template, label: undefined, id: template.id + '.toolbar' },
{
execute: (...args) => registry.executeCommand(template.id, ...args),
isEnabled: widget => this.withWidget(widget, () => this.workspaceService.opened),
isVisible: widget => this.withWidget(widget, () => this.workspaceService.opened)
}
);
}
}

override async registerToolbarItems(toolbarRegistry: TabBarToolbarRegistry): Promise<void> {
super.registerToolbarItems(toolbarRegistry);

for (const [id, template] of NEW_ELEMENT_TEMPLATES.entries()) {
toolbarRegistry.registerItem({
id: template.id + '.toolbar',
command: template.id + '.toolbar',
tooltip: 'New ' + template.label + '...',
priority: 2,
order: id.toString()
});
}
}
}

0 comments on commit a1d6d23

Please sign in to comment.