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

Allow multiple diagrams to be opened #44

Merged
merged 8 commits into from
Dec 8, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion e2e-tests/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ export default defineConfig({
retries: process.env.CI ? 1 : 0,
// The number of times to repeat each test, useful for debugging flaky tests
repeatEach: 1,
// Timeout for each test in milliseconds: 60 seconds.
// Fixture timeout (for each test) in milliseconds: 60 seconds.
timeout: 60 * 1000,
use: {
// Timeout for each single action in milliseconds: 5 seconds (make sure it's less hen the fixture timeout, so it will timeout before the whole tests times out)
actionTimeout: 5 * 1000,
// Timeout for each navigation action in milliseconds: 10 seconds (make sure it's less hen the fixture timeout, so it will timeout before the whole tests times out)
navigationTimeout: 10 * 1000,
baseURL: 'http://localhost:3000',
browserName: 'chromium',
screenshot: 'only-on-failure',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diagram:
id: "Example-diagram"
name: "Example-diagram"
nodes:
- id: "ExampleEntityNode"
entity: "unknown/ExampleEntity"
x: 463.96875
y: 251
width: 152.28289794921875
height: 94
edges:
- id: "ExampleEntityToExampleEntity"
relationship: "unknown/ExampleEntityToExampleEntity"
sourceNode: "ExampleEntityNode"
targetNode: "ExampleEntityNode"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
relationship:
id: "ExampleEntityToExampleEntity"
parent: "ExampleEntity"
child: "ExampleEntity"
type: "1:1"
22 changes: 22 additions & 0 deletions e2e-tests/src/tests/crossmodel-explorer-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,26 @@
expect(await menu.menuItemByNamePath('Open With', 'Code Editor')).toBeDefined();
expect(await menu.menuItemByNamePath('Open With', 'Form Editor')).toBeDefined();
});

test('code and form editor options available in the context menu on a relationship', async () => {
const file = await explorer.getFileStatNodeByLabel('exanple-relationship.relationship.cm');
expect(file).toBeDefined();
expect(await file.label()).toBe('exanple-relationship.relationship.cm');
const menu = await file.openContextMenu();

Check failure on line 31 in e2e-tests/src/tests/crossmodel-explorer-view.spec.ts

View workflow job for this annotation

GitHub Actions / build-and-test / ubuntu-latest

crossmodel-explorer-view.spec.ts:27:8 › CrossModel Explorer View › code and form editor options available in the context menu on a relationship

1) crossmodel-explorer-view.spec.ts:27:8 › CrossModel Explorer View › code and form editor options available in the context menu on a relationship TimeoutError: elementHandle.click: Timeout 5000ms exceeded. =========================== logs =========================== attempting click action waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #1 waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #2 waiting 20ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #3 waiting 100ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #4 waiting 100ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #5 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #6 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #7 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #8 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #9 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #10 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #11 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <div tabindex="-1" class="p-Widget p-Menu">…</div> intercepts pointer events retrying click action, attempt #12 waiting 500ms waiting for element to be visible, enabled and stable element is visible, enabled and stable scrolling into view if needed done scrolling <di
expect(await menu.isOpen()).toBe(true);
// Expect the Code and Form editor to be in the Open With menu option.
expect(await menu.menuItemByNamePath('Open With', 'Code Editor')).toBeDefined();
expect(await menu.menuItemByNamePath('Open With', 'Form Editor')).toBeDefined();
});

test('code and diagram editor options available in the context menu on a diagram', async () => {
const file = await explorer.getFileStatNodeByLabel('example-diagram.diagram.cm');
expect(file).toBeDefined();
expect(await file.label()).toBe('example-diagram.diagram.cm');
const menu = await file.openContextMenu();
expect(await menu.isOpen()).toBe(true);
// Expect the Code and Form editor to be in the Open With menu option.
expect(await menu.menuItemByNamePath('Open With', 'Code Editor')).toBeDefined();
expect(await menu.menuItemByNamePath('Open With', 'CrossModel Diagram')).toBeDefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Command, JsonOperationHandler, ModelState } from '@eclipse-glsp/server'
import { inject, injectable } from 'inversify';
import { DiagramNode, Entity } from '../../language-server/generated/ast.js';
import { createNodeToEntityReference } from '../../language-server/util/ast-util.js';
import { findNextId } from '../../language-server/util/name-util.js';
import { CrossModelState } from '../model/cross-model-state.js';
import { CrossModelCommand } from './cross-model-command.js';

Expand All @@ -34,7 +33,7 @@ export class CrossModelAddEntityOperationHandler extends JsonOperationHandler {
const node: DiagramNode = {
$type: DiagramNode,
$container: container,
id: findNextId(container, entityDescription.name + 'Node'),
id: this.modelState.idProvider.findNextId(DiagramNode, entityDescription.name + 'Node', container),
entity: {
$refText: entityDescription.name,
ref: entityDescription.node as Entity | undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class CrossModelCreateEdgeOperationHandler extends JsonCreateEdgeOperatio
const edge: DiagramEdge = {
$type: DiagramEdge,
$container: this.modelState.diagramRoot,
id: relationship.id,
id: this.modelState.idProvider.findNextId(DiagramEdge, relationship.id, this.modelState.diagramRoot),
relationship: {
ref: relationship,
$refText: this.modelState.idProvider.getExternalId(relationship) || relationship.id || ''
Expand All @@ -58,23 +58,23 @@ export class CrossModelCreateEdgeOperationHandler extends JsonCreateEdgeOperatio
const source = sourceNode.entity?.ref?.id || sourceNode.entity?.$refText;
const target = targetNode.entity?.ref?.id || targetNode.entity?.$refText;

// search for unique file name for the relationship and use file base name as relationship name
// if the user doesn't rename any files we should end up with unique names ;-)
const dirName = UriUtils.dirname(URI.parse(this.modelState.semanticUri));
const targetUri = UriUtils.joinPath(dirName, source + 'To' + target + '.relationship.cm');
const uri = Utils.findNewUri(targetUri);
const id = UriUtils.basename(uri).split('.')[0];

// create relationship, serialize and re-read to ensure everything is up to date and linked properly
const relationshipRoot: CrossModelRoot = { $type: 'CrossModelRoot' };
const relationship: Relationship = {
$type: Relationship,
$container: relationshipRoot,
id,
id: this.modelState.idProvider.findNextId(Relationship, source + 'To' + target),
type: '1:1',
parent: { $refText: sourceNode.entity?.$refText || '' },
child: { $refText: targetNode.entity?.$refText || '' }
};

// search for unique file name for the relationship and use file base name as relationship name
// if the user doesn't rename any files we should end up with unique names ;-)
const dirName = UriUtils.dirname(URI.parse(this.modelState.semanticUri));
const targetUri = UriUtils.joinPath(dirName, relationship.id + '.relationship.cm');
const uri = Utils.findNewUri(targetUri);

relationshipRoot.relationship = relationship;
const text = this.modelState.semanticSerializer.serialize(relationshipRoot);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Command, JsonOperationHandler } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { URI } from 'vscode-uri';
import { CrossModelRoot, DiagramNode, isCrossModelRoot } from '../../language-server/generated/ast.js';
import { findNextId } from '../../language-server/util/name-util.js';
import { CrossModelState } from '../model/cross-model-state.js';
import { CrossModelCommand } from './cross-model-command.js';

Expand Down Expand Up @@ -37,7 +36,7 @@ export class CrossModelDropEntityOperationHandler extends JsonOperationHandler {
const node: DiagramNode = {
$type: DiagramNode,
$container: container,
id: findNextId(container, root.entity.id + 'Node'),
id: this.modelState.idProvider.findNextId(DiagramNode, root.entity.id + 'Node', this.modelState.diagramRoot),
entity: {
$refText: this.modelState.idProvider.getExternalId(root.entity) || root.entity.id || '',
ref: root.entity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class CrossModelStorage implements SourceModelStorage, ClientSessionListe
this.state.setSemanticRoot(rootUri, root);
this.toDispose.push(
this.state.modelService.onUpdate<CrossModelRoot>(rootUri, async event => {
if (this.state.clientId !== event.sourceClientId) {
if (this.state.clientId !== event.sourceClientId || event.reason !== 'changed') {
this.state.setSemanticRoot(rootUri, event.model);
this.actionDispatcher.dispatchAll(await this.submissionHandler.submitModel('external'));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/

import { AstNode, CstNode, findNodeForProperty, NameProvider } from 'langium';
import { AstNode, CstNode, findNodeForProperty, isAstNode, NameProvider, streamAst } from 'langium';
import { CrossModelServices } from './cross-model-module.js';
import { UNKNOWN_PROJECT_REFERENCE } from './cross-model-package-manager.js';
import { findDocument } from './util/ast-util.js';
Expand All @@ -25,6 +25,9 @@ export interface IdProvider extends NameProvider {
getNodeId(node?: AstNode): string | undefined;
getLocalId(node?: AstNode): string | undefined;
getExternalId(node?: AstNode): string | undefined;

findNextId(type: string, proposal: string | undefined): string;
findNextId(type: string, proposal: string | undefined, container: AstNode): string;
}

/**
Expand Down Expand Up @@ -103,4 +106,38 @@ export class DefaultIdProvider implements NameProvider, IdProvider {
getNameNode(node: AstNode): CstNode | undefined {
return findNodeForProperty(node.$cstNode, ID_PROPERTY);
}

findNextId(type: string, proposal: string | undefined): string;
findNextId(type: string, proposal: string | undefined, container: AstNode): string;
findNextId(type: string, proposal: string | undefined, container?: AstNode): string {
if (isAstNode(container)) {
return this.findNextIdInContainer(type, proposal ?? 'Element', container);
}
return this.findNextIdInIndex(type, proposal ?? 'Element');
}

protected findNextIdInContainer(type: string, proposal: string, container: AstNode): string {
const knownIds = streamAst(container)
.filter(node => node.$type === type)
.map(this.getNodeId)
.nonNullable()
.toArray();
return this.countToNextId(knownIds, proposal);
}

protected findNextIdInIndex(type: string, proposal: string): string {
const knownIds = this.services.shared.workspace.IndexManager.allElements(type)
.map(element => element.name)
.toArray();
return this.countToNextId(knownIds, proposal);
}

protected countToNextId(knownIds: string[], proposal: string): string {
let nextId = proposal;
let counter = 1;
while (knownIds.includes(nextId)) {
nextId = proposal + counter++;
}
return nextId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ID_PROPERTY } from './cross-model-naming.js';
import {
CrossModelAstType,
DiagramEdge,
SystemDiagram,
isDiagramEdge,
isDiagramNode,
isEntity,
Expand All @@ -24,7 +25,8 @@ export function registerValidationChecks(services: CrossModelServices): void {

const checks: ValidationChecks<CrossModelAstType> = {
AstNode: validator.checkUniqueId,
DiagramEdge: validator.checkDiagramEdge
DiagramEdge: validator.checkDiagramEdge,
SystemDiagram: validator.checkUniqueIdWithinDiagram
};
registry.register(checks, validator);
}
Expand Down Expand Up @@ -61,6 +63,24 @@ export class CrossModelValidator {
);
}

checkUniqueIdWithinDiagram(diagram: SystemDiagram, accept: ValidationAcceptor): void {
const knownIds: string[] = [];
for (const node of diagram.nodes) {
if (node.id && knownIds.includes(node.id)) {
accept('error', 'Must provide a unique id.', { node, property: ID_PROPERTY });
} else if (node.id) {
knownIds.push(node.id);
}
}
for (const edge of diagram.edges) {
if (edge.id && knownIds.includes(edge.id)) {
accept('error', 'Must provide a unique id.', { node: edge, property: ID_PROPERTY });
} else if (edge.id) {
knownIds.push(edge.id);
}
}
}

checkDiagramEdge(edge: DiagramEdge, accept: ValidationAcceptor): void {
if (edge.sourceNode?.ref?.entity?.ref?.$type !== edge.relationship?.ref?.parent?.ref?.$type) {
accept('error', 'Source must match type of parent', { node: edge, property: 'sourceNode' });
Expand Down
14 changes: 0 additions & 14 deletions extensions/crossmodel-lang/src/language-server/util/name-util.ts

This file was deleted.

5 changes: 3 additions & 2 deletions extensions/crossmodel-lang/src/model-server/model-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class ModelServer implements Disposable {
const entity: Entity | undefined = diagramNode?.entity?.ref;

if (!entity?.$container.$document) {
throw new Error('No node found with the given id');
throw new Error('No node found with the given id: ' + id + ' (in ' + uri + ')');
}

const serializedEntity = toSerializable({
Expand Down Expand Up @@ -112,7 +112,8 @@ export class ModelServer implements Disposable {
this.connection.sendNotification(OnUpdated, {
uri: args.uri,
model: toSerializable(event.model) as CrossModelRoot,
sourceClientId: event.sourceClientId
sourceClientId: event.sourceClientId,
reason: event.reason
})
)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { CrossModelLanguageMetaData } from '../language-server/generated/module.
import { AddedSharedModelServices } from './model-module.js';
import { OpenableTextDocuments } from './openable-text-documents.js';

export interface UpdateInfo {
changed: URI[];
deleted: URI[];
}

/**
* A manager class that supports handling documents with a simple open-update-save/close lifecycle.
*
Expand All @@ -32,6 +37,8 @@ export class OpenTextDocumentManager {
protected langiumDocs: LangiumDocuments;
protected documentBuilder: DocumentBuilder;

protected lastUpdate?: UpdateInfo;

constructor(services: AddedSharedModelServices & LangiumDefaultSharedServices) {
this.textDocuments = services.workspace.TextDocuments;
this.fileSystemProvider = services.workspace.FileSystemProvider;
Expand All @@ -42,6 +49,9 @@ export class OpenTextDocumentManager {
this.open({ clientId: event.clientId, uri: event.document.uri, languageId: event.document.languageId })
);
this.textDocuments.onDidClose(event => this.close({ clientId: event.clientId, uri: event.document.uri }));
this.documentBuilder.onUpdate((changed, deleted) => {
this.lastUpdate = { changed, deleted };
});
}

/**
Expand Down Expand Up @@ -72,11 +82,17 @@ export class OpenTextDocumentManager {
const changedDocument = allChangedDocuments.find(document => document.uri.toString() === uri);
if (changedDocument) {
const sourceClientId = this.getSourceClientId(changedDocument, allChangedDocuments);
listener({
const event: ModelUpdatedEvent<T> = {
model: changedDocument.parseResult.value as T,
sourceClientId,
uri: changedDocument.textDocument.uri
});
uri: changedDocument.textDocument.uri,
reason: this.lastUpdate?.changed.includes(changedDocument.uri)
? 'changed'
: this.lastUpdate?.deleted.includes(changedDocument.uri)
? 'deleted'
: 'updated'
};
listener(event);
}
});
}
Expand Down Expand Up @@ -133,7 +149,7 @@ export class OpenTextDocumentManager {
}

protected createDummyDocument(uri: string): TextDocumentItem {
return TextDocumentItem.create(this.normalizedUri(uri), CrossModelLanguageMetaData.languageId, 1, '');
return TextDocumentItem.create(this.normalizedUri(uri), CrossModelLanguageMetaData.languageId, 0, '');
}

protected async createDocumentFromFileSystem(
Expand All @@ -142,7 +158,7 @@ export class OpenTextDocumentManager {
): Promise<TextDocumentItem> {
const vscUri = URI.parse(uri);
const content = this.fileSystemProvider.readFileSync(vscUri);
return TextDocumentItem.create(vscUri.toString(), languageId, 1, content.toString());
return TextDocumentItem.create(vscUri.toString(), languageId, 0, content.toString());
}

protected normalizedUri(uri: string): string {
Expand Down
Loading
Loading