diff --git a/extensions/crossmodel-lang/package.json b/extensions/crossmodel-lang/package.json index fe8b7d83..04114a48 100644 --- a/extensions/crossmodel-lang/package.json +++ b/extensions/crossmodel-lang/package.json @@ -87,7 +87,7 @@ ] }, "activationEvents": [ - "onLanguage:cross-model" + "onStartupFinished" ], "dependencies": { "@crossbreeze/protocol": "0.0.0", diff --git a/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts b/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts index 636f55f9..93f148e6 100644 --- a/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts +++ b/extensions/crossmodel-lang/src/glsp-server/handler/create-edge-operation-handler.ts @@ -84,7 +84,7 @@ export class CrossModelCreateEdgeOperationHandler extends OperationHandler imple relationshipRoot.relationship = relationship; const text = this.state.semanticSerializer.serialize(relationshipRoot); - await this.state.modelService.save(uri.toString(), text); + await this.state.modelService.save({ uri: uri.toString(), model: text, clientId: this.state.clientId }); const root = await this.state.modelService.request(uri.toString(), isCrossModelRoot); return root?.relationship; } diff --git a/extensions/crossmodel-lang/src/glsp-server/launch.ts b/extensions/crossmodel-lang/src/glsp-server/launch.ts index ebdeb6af..933a5722 100644 --- a/extensions/crossmodel-lang/src/glsp-server/launch.ts +++ b/extensions/crossmodel-lang/src/glsp-server/launch.ts @@ -25,7 +25,7 @@ import { CrossModelLayoutConfigurator } from './layout/cross-model-layout-config * @returns a promise that is resolved as soon as the server is shut down or rejects if an error occurs */ export function startGLSPServer(services: CrossModelLSPServices, workspaceFolder: URI): MaybePromise { - const launchOptions: SocketLaunchOptions = { ...defaultSocketLaunchOptions, logLevel: LogLevel.debug }; + const launchOptions: SocketLaunchOptions = { ...defaultSocketLaunchOptions, logLevel: LogLevel.info }; // create module based on launch options, e.g., logging etc. const appModule = createAppModule(launchOptions); diff --git a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts index 500402f8..d1b6f924 100644 --- a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts +++ b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-state.ts @@ -60,7 +60,11 @@ export class CrossModelState extends DefaultModelState { } async updateSemanticRoot(content?: string): Promise { - this._semanticRoot = await this.modelService.update(this.semanticUri, content ?? this.semanticRoot); + this._semanticRoot = await this.modelService.update({ + uri: this.semanticUri, + model: content ?? this.semanticRoot, + clientId: this.clientId + }); this.index.indexSemanticRoot(this.semanticRoot); } diff --git a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts index 6b492d0a..f258675b 100644 --- a/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts +++ b/extensions/crossmodel-lang/src/glsp-server/model/cross-model-storage.ts @@ -3,18 +3,20 @@ ********************************************************************************/ import { - ActionDispatcher, - ClientSession, - ClientSessionListener, - ClientSessionManager, - GLSPServerError, - Logger, - MaybePromise, - ModelSubmissionHandler, - RequestModelAction, - SOURCE_URI_ARG, - SaveModelAction, - SourceModelStorage + ActionDispatcher, + ClientSession, + ClientSessionListener, + ClientSessionManager, + Disposable, + DisposableCollection, + GLSPServerError, + Logger, + MaybePromise, + ModelSubmissionHandler, + RequestModelAction, + SOURCE_URI_ARG, + SaveModelAction, + SourceModelStorage } from '@eclipse-glsp/server'; import { inject, injectable, postConstruct } from 'inversify'; import { findRootNode, streamReferences } from 'langium'; @@ -29,62 +31,71 @@ import { CrossModelState } from './cross-model-state'; */ @injectable() export class CrossModelStorage implements SourceModelStorage, ClientSessionListener { - @inject(Logger) protected logger: Logger; - @inject(CrossModelState) protected state: CrossModelState; - @inject(ClientSessionManager) protected sessionManager: ClientSessionManager; - @inject(ModelSubmissionHandler) protected submissionHandler: ModelSubmissionHandler; - @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; + @inject(Logger) protected logger: Logger; + @inject(CrossModelState) protected state: CrossModelState; + @inject(ClientSessionManager) protected sessionManager: ClientSessionManager; + @inject(ModelSubmissionHandler) protected submissionHandler: ModelSubmissionHandler; + @inject(ActionDispatcher) protected actionDispatcher: ActionDispatcher; - @postConstruct() - protected init(): void { - this.sessionManager.addListener(this, this.state.clientId); - } + protected toDispose = new DisposableCollection(); - async loadSourceModel(action: RequestModelAction): Promise { - // load semantic model from document in language model service - const sourceUri = this.getSourceUri(action); - const rootUri = URI.file(sourceUri).toString(); - const root = await this.state.modelService.request(rootUri, isCrossModelRoot); - if (!root || !root.diagram) { - throw new GLSPServerError('Expected CrossModal Diagram Root'); - } - this.state.setSemanticRoot(rootUri, root); - this.state.modelService.onUpdate(rootUri, async newRoot => { - this.state.setSemanticRoot(rootUri, newRoot); - this.actionDispatcher.dispatchAll(await this.submissionHandler.submitModel('external')); - }); - } + @postConstruct() + protected init(): void { + this.sessionManager.addListener(this, this.state.clientId); + } - saveSourceModel(action: SaveModelAction): MaybePromise { - const saveUri = this.getFileUri(action); + async loadSourceModel(action: RequestModelAction): Promise { + // load semantic model from document in language model service + const sourceUri = this.getSourceUri(action); + const rootUri = URI.file(sourceUri).toString(); + await this.state.modelService.open({ uri: rootUri, clientId: this.state.clientId }); + this.toDispose.push(Disposable.create(() => this.state.modelService.close({ uri: rootUri, clientId: this.state.clientId }))); + const root = await this.state.modelService.request(rootUri, isCrossModelRoot); + if (!root || !root.diagram) { + throw new GLSPServerError('Expected CrossModal Diagram Root'); + } + this.state.setSemanticRoot(rootUri, root); + this.toDispose.push( + this.state.modelService.onUpdate(rootUri, async event => { + if (this.state.clientId !== event.sourceClientId) { + this.state.setSemanticRoot(rootUri, event.model); + this.actionDispatcher.dispatchAll(await this.submissionHandler.submitModel('external')); + } + }) + ); + } - // save document and all related documents - this.state.modelService.save(saveUri, this.state.semanticRoot); - streamReferences(this.state.semanticRoot) - .map(refInfo => refInfo.reference.ref) - .nonNullable() - .map(ref => findRootNode(ref)) - .forEach(root => this.state.modelService.save(root.$document!.uri.toString(), root)); - } + saveSourceModel(action: SaveModelAction): MaybePromise { + const saveUri = this.getFileUri(action); - sessionDisposed(_clientSession: ClientSession): void { - // close loaded document for modification - this.state.modelService.close(this.state.semanticUri); - } + // save document and all related documents + this.state.modelService.save({ uri: saveUri, model: this.state.semanticRoot, clientId: this.state.clientId }); + streamReferences(this.state.semanticRoot) + .map(refInfo => refInfo.reference.ref) + .nonNullable() + .map(ref => findRootNode(ref)) + .forEach(root => + this.state.modelService.save({ uri: root.$document!.uri.toString(), model: root, clientId: this.state.clientId }) + ); + } - protected getSourceUri(action: RequestModelAction): string { - const sourceUri = action.options?.[SOURCE_URI_ARG]; - if (typeof sourceUri !== 'string') { - throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`); - } - return sourceUri; - } + sessionDisposed(_clientSession: ClientSession): void { + this.toDispose.dispose(); + } - protected getFileUri(action: SaveModelAction): string { - const uri = action.fileUri ?? this.state.get(SOURCE_URI_ARG); - if (!uri) { - throw new GLSPServerError('Could not derive fileUri for saving the current source model'); - } - return uri; - } + protected getSourceUri(action: RequestModelAction): string { + const sourceUri = action.options?.[SOURCE_URI_ARG]; + if (typeof sourceUri !== 'string') { + throw new GLSPServerError(`Invalid RequestModelAction! Missing argument with key '${SOURCE_URI_ARG}'`); + } + return sourceUri; + } + + protected getFileUri(action: SaveModelAction): string { + const uri = action.fileUri ?? this.state.get(SOURCE_URI_ARG); + if (!uri) { + throw new GLSPServerError('Could not derive fileUri for saving the current source model'); + } + return uri; + } } diff --git a/extensions/crossmodel-lang/src/language-server/cross-model-module.ts b/extensions/crossmodel-lang/src/language-server/cross-model-module.ts index f1d8841b..593c4283 100644 --- a/extensions/crossmodel-lang/src/language-server/cross-model-module.ts +++ b/extensions/crossmodel-lang/src/language-server/cross-model-module.ts @@ -95,7 +95,7 @@ export const CrossModelSharedModule: Module< WorkspaceManager: services => new CrossModelWorkspaceManager(services), PackageManager: services => new CrossModelPackageManager(services), LangiumDocuments: services => new CrossModelLangiumDocuments(services), - TextDocuments: () => new OpenableTextDocuments(TextDocument), + TextDocuments: services => new OpenableTextDocuments(TextDocument, services.logger.ClientLogger), TextDocumentManager: services => new OpenTextDocumentManager(services), DocumentBuilder: services => new CrossModelDocumentBuilder(services) }, diff --git a/extensions/crossmodel-lang/src/model-server/model-server.ts b/extensions/crossmodel-lang/src/model-server/model-server.ts index eaa260ab..a505c754 100644 --- a/extensions/crossmodel-lang/src/model-server/model-server.ts +++ b/extensions/crossmodel-lang/src/model-server/model-server.ts @@ -4,16 +4,20 @@ import { CloseModel, + CloseModelArgs, CrossModelRoot, OnSave, OnUpdated, OpenModel, + OpenModelArgs, RequestModel, RequestModelDiagramNode, SaveModel, - UpdateModel + SaveModelArgs, + UpdateModel, + UpdateModelArgs } from '@crossbreeze/protocol'; -import { AstNode, isReference } from 'langium'; +import { isReference } from 'langium'; import { Disposable } from 'vscode-jsonrpc'; import * as rpc from 'vscode-jsonrpc/node'; import { CrossModelRoot as CrossModelRootAst, DiagramNode, Entity, isCrossModelRoot } from '../language-server/generated/ast'; @@ -26,18 +30,19 @@ import { ModelService } from './model-service'; */ export class ModelServer implements Disposable { protected toDispose: Disposable[] = []; + protected toDisposeForSession: Map = new Map(); constructor(protected connection: rpc.MessageConnection, protected modelService: ModelService) { this.initialize(connection); } protected initialize(connection: rpc.MessageConnection): void { - this.toDispose.push(connection.onRequest(OpenModel, uri => this.openModel(uri))); - this.toDispose.push(connection.onRequest(CloseModel, uri => this.closeModel(uri))); + this.toDispose.push(connection.onRequest(OpenModel, args => this.openModel(args))); + this.toDispose.push(connection.onRequest(CloseModel, args => this.closeModel(args))); this.toDispose.push(connection.onRequest(RequestModel, uri => this.requestModel(uri))); this.toDispose.push(connection.onRequest(RequestModelDiagramNode, (uri, id) => this.requestModelDiagramNode(uri, id))); - this.toDispose.push(connection.onRequest(UpdateModel, (uri, model) => this.updateModel(uri, model))); - this.toDispose.push(connection.onRequest(SaveModel, (uri, model) => this.saveModel(uri, model))); + this.toDispose.push(connection.onRequest(UpdateModel, args => this.updateModel(args))); + this.toDispose.push(connection.onRequest(SaveModel, args => this.saveModel(args))); } /** @@ -81,21 +86,44 @@ export class ModelServer implements Disposable { }; } - protected async openModel(uri: string): Promise { - await this.modelService.open(uri); - - this.modelService.onSave(uri, newModel => { - // TODO: Research if this also has to be closed after the document closes - this.connection.sendNotification(OnSave, uri, toSerializable(newModel) as CrossModelRoot); - }); - this.modelService.onUpdate(uri, newModel => { - // TODO: Research if this also has to be closed after the document closes - this.connection.sendNotification(OnUpdated, uri, toSerializable(newModel) as CrossModelRoot); - }); + protected async openModel(args: OpenModelArgs): Promise { + if (!this.modelService.isOpen(args.uri)) { + await this.modelService.open(args); + } + this.setupListeners(args); + return this.requestModel(args.uri); + } + + protected setupListeners(args: OpenModelArgs): void { + this.disposeListeners(args); + const listenersForClient = []; + listenersForClient.push( + this.modelService.onSave(args.uri, event => + this.connection.sendNotification(OnSave, { + uri: args.uri, + model: toSerializable(event.model) as CrossModelRoot, + sourceClientId: event.sourceClientId + }) + ), + this.modelService.onUpdate(args.uri, event => + this.connection.sendNotification(OnUpdated, { + uri: args.uri, + model: toSerializable(event.model) as CrossModelRoot, + sourceClientId: event.sourceClientId + }) + ) + ); + this.toDisposeForSession.set(args.clientId, listenersForClient); + } + + protected disposeListeners(args: CloseModelArgs): void { + this.toDisposeForSession.get(args.clientId)?.forEach(disposable => disposable.dispose()); + this.toDisposeForSession.delete(args.clientId); } - protected async closeModel(uri: string): Promise { - await this.modelService.close(uri); + protected async closeModel(args: CloseModelArgs): Promise { + this.disposeListeners(args); + return this.modelService.close(args); } protected async requestModel(uri: string): Promise { @@ -103,13 +131,13 @@ export class ModelServer implements Disposable { return toSerializable(root) as CrossModelRoot; } - protected async updateModel(uri: string, model: CrossModelRoot): Promise { - const updated = await this.modelService.update(uri, model); + protected async updateModel(args: UpdateModelArgs): Promise { + const updated = await this.modelService.update(args); return toSerializable(updated) as CrossModelRoot; } - protected async saveModel(uri: string, model: AstNode): Promise { - await this.modelService.save(uri, model); + protected async saveModel(args: SaveModelArgs): Promise { + await this.modelService.save(args); } dispose(): void { diff --git a/extensions/crossmodel-lang/src/model-server/model-service.ts b/extensions/crossmodel-lang/src/model-server/model-service.ts index 935598fd..86e36591 100644 --- a/extensions/crossmodel-lang/src/model-server/model-service.ts +++ b/extensions/crossmodel-lang/src/model-server/model-service.ts @@ -2,11 +2,12 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CloseModelArgs, ModelSavedEvent, ModelUpdatedEvent, OpenModelArgs, SaveModelArgs, UpdateModelArgs } from '@crossbreeze/protocol'; import { AstNode, DocumentState, isAstNode } from 'langium'; -import { Disposable } from 'vscode-languageserver'; -import { OptionalVersionedTextDocumentIdentifier, Range, TextDocumentEdit, TextEdit } from 'vscode-languageserver-protocol'; +import { Disposable, OptionalVersionedTextDocumentIdentifier, Range, TextDocumentEdit, TextEdit, uinteger } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import { CrossModelSharedServices } from '../language-server/cross-model-module'; +import { LANGUAGE_CLIENT_ID } from './openable-text-documents'; /** * The model service serves as a facade to access and update semantic models from the language server as a non-LSP client. @@ -18,15 +19,44 @@ export class ModelService { protected documentManager = shared.workspace.TextDocumentManager, protected documents = shared.workspace.LangiumDocuments, protected documentBuilder = shared.workspace.DocumentBuilder - ) {} + ) { + // sync updates with language client + this.documentBuilder.onBuildPhase(DocumentState.Validated, (allChangedDocuments, _token) => { + for (const changedDocument of allChangedDocuments) { + const sourceClientId = this.documentManager.getSourceClientId(changedDocument, allChangedDocuments); + if (sourceClientId === LANGUAGE_CLIENT_ID) { + continue; + } + const textDocument = changedDocument.textDocument; + if (this.documentManager.isOpenInLanguageClient(textDocument.uri)) { + // we only want to apply a text edit if the editor is already open + // because opening and updating at the same time might cause problems as the open call resets the document to filesystem + this.shared.lsp.Connection?.workspace.applyEdit({ + label: 'Update Model', + documentChanges: [ + // we use a null version to indicate that the version is known + // eslint-disable-next-line no-null/no-null + TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(textDocument.uri, null), [ + TextEdit.replace(Range.create(0, 0, uinteger.MAX_VALUE, uinteger.MAX_VALUE), textDocument.getText()) + ]) + ] + }); + } + } + }); + } /** * Opens the document with the given URI for modification. * * @param uri document URI */ - async open(uri: string): Promise { - return this.documentManager.open(uri); + async open(args: OpenModelArgs): Promise { + return this.documentManager.open(args); + } + + isOpen(uri: string): boolean { + return this.documentManager.isOpen(uri); } /** @@ -34,8 +64,8 @@ export class ModelService { * * @param uri document URI */ - async close(uri: string): Promise { - return this.documentManager.close(uri); + async close(args: CloseModelArgs): Promise { + return this.documentManager.close(args); } /** @@ -54,7 +84,6 @@ export class ModelService { */ request(uri: string, guard: (item: unknown) => item is T): Promise; async request(uri: string, guard?: (item: unknown) => item is T): Promise { - this.open(uri); const document = this.documents.getOrCreateDocument(URI.parse(uri)); const root = document.parseResult.value; const check = guard ?? isAstNode; @@ -70,50 +99,27 @@ export class ModelService { * @param model semantic model or textual representation of it * @returns the stored semantic model */ - async update(uri: string, model: T | string): Promise { - await this.open(uri); - const document = this.documents.getOrCreateDocument(URI.parse(uri)); + async update(args: UpdateModelArgs): Promise { + await this.open(args); + const document = this.documents.getOrCreateDocument(URI.parse(args.uri)); const root = document.parseResult.value; if (!isAstNode(root)) { - throw new Error(`No AST node to update exists in '${uri}'`); + throw new Error(`No AST node to update exists in '${args.uri}'`); } const textDocument = document.textDocument; - const text = typeof model === 'string' ? model : this.serialize(URI.parse(uri), model); + const text = typeof args.model === 'string' ? args.model : this.serialize(URI.parse(args.uri), args.model); if (text === textDocument.getText()) { return document.parseResult.value as T; } - - if (this.documentManager.isOpenInTextEditor(uri)) { - // we only want to apply a text edit if the editor is already open - // because opening and updating at the same time might cause problems as the open call resets the document to filesystem - await this.shared.lsp.Connection?.workspace.applyEdit({ - label: 'Update Model', - documentChanges: [ - // we use a null version to indicate that the version is known - // eslint-disable-next-line no-null/no-null - TextDocumentEdit.create(OptionalVersionedTextDocumentIdentifier.create(textDocument.uri, null), [ - TextEdit.replace(Range.create(0, 0, textDocument.lineCount, textDocument.getText().length), text) - ]) - ] - }); - } - - await this.documentManager.update(uri, textDocument.version + 1, text); - await this.documentBuilder.update([URI.parse(uri)], []); - + await this.documentManager.update(args.uri, textDocument.version + 1, text, args.clientId); return document.parseResult.value as T; } - onUpdate(uri: string, listener: (model: T) => void): Disposable { - return this.documentBuilder.onBuildPhase(DocumentState.Validated, (allChangedDocuments, _token) => { - const changedDocument = allChangedDocuments.find(document => document.uri.toString() === uri); - if (changedDocument) { - listener(changedDocument.parseResult.value as T); - } - }); + onUpdate(uri: string, listener: (model: ModelUpdatedEvent) => void): Disposable { + return this.documentManager.onUpdate(uri, listener); } - onSave(uri: string, listener: (model: T) => void): Disposable { + onSave(uri: string, listener: (model: ModelSavedEvent) => void): Disposable { return this.documentManager.onSave(uri, listener); } @@ -123,14 +129,14 @@ export class ModelService { * @param uri document uri * @param model semantic model or text */ - async save(uri: string, model: AstNode | string): Promise { + async save(args: SaveModelArgs): Promise { // sync: implicit update of internal data structure to match file system (similar to workspace initialization) - if (this.documents.hasDocument(URI.parse(uri))) { - await this.update(uri, model); + if (this.documents.hasDocument(URI.parse(args.uri))) { + await this.update(args); } - const text = typeof model === 'string' ? model : this.serialize(URI.parse(uri), model); - return this.documentManager.save(uri, text); + const text = typeof args.model === 'string' ? args.model : this.serialize(URI.parse(args.uri), args.model); + return this.documentManager.save(args.uri, text, args.clientId); } /** diff --git a/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts b/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts index df7a2056..96e15f50 100644 --- a/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts +++ b/extensions/crossmodel-lang/src/model-server/open-text-document-manager.ts @@ -2,12 +2,22 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CloseModelArgs, ModelSavedEvent, ModelUpdatedEvent, OpenModelArgs } from '@crossbreeze/protocol'; import * as fs from 'fs'; -import { AstNode, FileSystemProvider, LangiumDefaultSharedServices, LangiumDocuments } from 'langium'; +import { + AstNode, + DocumentBuilder, + DocumentState, + FileSystemProvider, + LangiumDefaultSharedServices, + LangiumDocument, + LangiumDocuments +} from 'langium'; import { Disposable } from 'vscode-languageserver'; import { TextDocumentIdentifier, TextDocumentItem, VersionedTextDocumentIdentifier } from 'vscode-languageserver-protocol'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { URI } from 'vscode-uri'; +import { CrossModelLanguageMetaData } from '../language-server/generated/module'; import { AddedSharedModelServices } from './model-module'; import { OpenableTextDocuments } from './openable-text-documents'; @@ -20,17 +30,18 @@ export class OpenTextDocumentManager { protected textDocuments: OpenableTextDocuments; protected fileSystemProvider: FileSystemProvider; protected langiumDocs: LangiumDocuments; - - /** Normalized URIs of open documents */ - protected openDocuments: string[] = []; + protected documentBuilder: DocumentBuilder; constructor(services: AddedSharedModelServices & LangiumDefaultSharedServices) { this.textDocuments = services.workspace.TextDocuments; this.fileSystemProvider = services.workspace.FileSystemProvider; this.langiumDocs = services.workspace.LangiumDocuments; + this.documentBuilder = services.workspace.DocumentBuilder; - this.textDocuments.onDidOpen(event => this.open(event.document.uri, event.document.languageId)); - this.textDocuments.onDidClose(event => this.close(event.document.uri)); + this.textDocuments.onDidOpen(event => + 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 })); } /** @@ -41,67 +52,94 @@ export class OpenTextDocumentManager { * @param listener Callback to be called * @returns Disposable object */ - onSave(uri: string, listener: (model: T) => void): Disposable { - return this.textDocuments.onDidSave(e => { - const documentURI = URI.parse(e.document.uri); + onSave(uri: string, listener: (model: ModelSavedEvent) => void): Disposable { + return this.textDocuments.onDidSave(event => { + const documentURI = URI.parse(event.document.uri); // Check if the uri of the saved document and the uri of the listener are equal. - if (e.document.uri === uri && documentURI !== undefined && this.langiumDocs.hasDocument(documentURI)) { + if (event.document.uri === uri && documentURI !== undefined && this.langiumDocs.hasDocument(documentURI)) { const document = this.langiumDocs.getOrCreateDocument(documentURI); const root = document.parseResult.value; - return listener(root as T); + return listener({ model: root as T, uri: event.document.uri, sourceClientId: event.clientId }); } return undefined; }); } - async open(uri: string, languageId?: string): Promise { - if (this.isOpen(uri)) { - return; - } - this.openDocuments.push(this.normalizedUri(uri)); - const textDocument = await this.readFromFilesystem(uri, languageId); - this.textDocuments.notifyDidOpenTextDocument({ textDocument }, false); + onUpdate(uri: string, listener: (model: ModelUpdatedEvent) => void): Disposable { + return this.documentBuilder.onBuildPhase(DocumentState.Validated, (allChangedDocuments, _token) => { + const changedDocument = allChangedDocuments.find(document => document.uri.toString() === uri); + if (changedDocument) { + const sourceClientId = this.getSourceClientId(changedDocument, allChangedDocuments); + listener({ + model: changedDocument.parseResult.value as T, + sourceClientId, + uri: changedDocument.textDocument.uri + }); + } + }); } - async close(uri: string): Promise { - if (!this.isOpen(uri)) { - return; + getSourceClientId(preferred: LangiumDocument, rest: LangiumDocument[]): string { + const clientId = this.textDocuments.getChangeSource(preferred.textDocument.uri, preferred.textDocument.version); + if (clientId) { + return clientId; } - this.removeFromOpenedDocuments(uri); - this.textDocuments.notifyDidCloseTextDocument({ textDocument: TextDocumentIdentifier.create(uri) }); + return ( + rest + .map(document => this.textDocuments.getChangeSource(document.textDocument.uri, document.textDocument.version)) + .find(source => source !== undefined) || 'unknown' + ); } - async update(uri: string, version: number, text: string): Promise { + async open(args: OpenModelArgs): Promise { + // only create a dummy document if it is already open as we use the synced state anyway + const textDocument = this.isOpen(args.uri) + ? this.createDummyDocument(args.uri) + : await this.createDocumentFromFileSystem(args.uri, args.languageId); + this.textDocuments.notifyDidOpenTextDocument({ textDocument }, args.clientId); + } + + async close(args: CloseModelArgs): Promise { + this.textDocuments.notifyDidCloseTextDocument({ textDocument: TextDocumentIdentifier.create(args.uri) }, args.clientId); + } + + async update(uri: string, version: number, text: string, clientId: string): Promise { if (!this.isOpen(uri)) { throw new Error(`Document ${uri} hasn't been opened for updating yet`); } - this.textDocuments.notifyDidChangeTextDocument({ - textDocument: VersionedTextDocumentIdentifier.create(uri, version), - contentChanges: [{ text }] - }); + this.textDocuments.notifyDidChangeTextDocument( + { + textDocument: VersionedTextDocumentIdentifier.create(uri, version), + contentChanges: [{ text }] + }, + clientId + ); } - async save(uri: string, text: string): Promise { + async save(uri: string, text: string, clientId: string): Promise { const vscUri = URI.parse(uri); fs.writeFileSync(vscUri.fsPath, text); - this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(uri), text }); + this.textDocuments.notifyDidSaveTextDocument({ textDocument: TextDocumentIdentifier.create(uri), text }, clientId); } isOpen(uri: string): boolean { - return this.openDocuments.includes(this.normalizedUri(uri)); + return !!this.textDocuments.get(this.normalizedUri(uri)) || !!this.textDocuments.get(uri); } - isOpenInTextEditor(uri: string): boolean { - return this.textDocuments.isOpenInTextEditor(this.normalizedUri(uri)); + isOpenInLanguageClient(uri: string): boolean { + return this.textDocuments.isOpenInLanguageClient(this.normalizedUri(uri)); } - protected removeFromOpenedDocuments(uri: string): void { - this.openDocuments.splice(this.openDocuments.indexOf(this.normalizedUri(uri))); + protected createDummyDocument(uri: string): TextDocumentItem { + return TextDocumentItem.create(this.normalizedUri(uri), CrossModelLanguageMetaData.languageId, 1, ''); } - protected async readFromFilesystem(uri: string, languageId = 'cross-model'): Promise { + protected async createDocumentFromFileSystem( + uri: string, + languageId: string = CrossModelLanguageMetaData.languageId + ): Promise { const vscUri = URI.parse(uri); const content = this.fileSystemProvider.readFileSync(vscUri); return TextDocumentItem.create(vscUri.toString(), languageId, 1, content.toString()); diff --git a/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts b/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts index 595b9057..bae68d1d 100644 --- a/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts +++ b/extensions/crossmodel-lang/src/model-server/openable-text-documents.ts @@ -3,185 +3,248 @@ * Copyright (c) Microsoft Corporation and EclipseSource. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ +import { basename } from 'path'; import { - CancellationToken, - DidChangeTextDocumentParams, - DidCloseTextDocumentParams, - DidOpenTextDocumentParams, - DidSaveTextDocumentParams, - Disposable, - Emitter, - HandlerResult, - RequestHandler, - TextDocumentChangeEvent, - TextDocuments, - TextDocumentsConfiguration, - TextDocumentSyncKind, - TextDocumentWillSaveEvent, - TextEdit, - WillSaveTextDocumentParams + CancellationToken, + DidChangeTextDocumentParams, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DidSaveTextDocumentParams, + Disposable, + Emitter, + Event, + HandlerResult, + RequestHandler, + TextDocumentChangeEvent, + TextDocuments, + TextDocumentsConfiguration, + TextDocumentSyncKind, + TextDocumentWillSaveEvent, + TextEdit, + WillSaveTextDocumentParams } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import { URI } from 'vscode-uri'; +import { ClientLogger } from '../language-server/cross-model-client-logger'; -const OPENED_IN_TEXT_EDITOR_PROP = '_openedInTextEditor'; +export const LANGUAGE_CLIENT_ID = 'language-client'; + +export interface ClientTextDocumentChangeEvent extends TextDocumentChangeEvent { + clientId: string; +} /** - * This subclass of `TextDocuments` is actually entirely equivalent to `TextDocuments` but opens up - * methods to be able to invoke events from within the language server (see json-server.ts). - * - * Otherwise the document will be read all the time from the disk - * langium/src/workspace/documents.ts:222 which relies on _syncedDocuments to be open - * vscode-languageserver/lib/common/textDocuments.js:119 + * This subclass of `TextDocuments` supports multiple clients to open the same document and sync their state. */ export class OpenableTextDocuments extends TextDocuments { - public constructor(protected configuration: TextDocumentsConfiguration) { - super(configuration); - } - - protected get __syncedDocuments(): Map { - return this['_syncedDocuments']; - } - - protected get __onDidChangeContent(): Emitter> { - return this['_onDidChangeContent']; - } - - protected get __onDidOpen(): Emitter> { - return this['_onDidOpen']; - } - - protected get __onDidClose(): Emitter> { - return this['_onDidClose']; - } - - protected get __onDidSave(): Emitter> { - return this['_onDidSave']; - } - - protected get __onWillSave(): Emitter> { - return this['_onWillSave']; - } - - protected get __willSaveWaitUntil(): RequestHandler, TextEdit[], void> | undefined { - return this['_willSaveWaitUntil']; - } - - public override listen(connection: any): Disposable { - (connection).__textDocumentSync = TextDocumentSyncKind.Incremental; - const disposables: Disposable[] = []; - disposables.push( - connection.onDidOpenTextDocument((event: DidOpenTextDocumentParams) => { - this.notifyDidOpenTextDocument(event); - }) - ); - disposables.push( - connection.onDidChangeTextDocument((event: DidChangeTextDocumentParams) => { - this.notifyDidChangeTextDocument(event); - }) - ); - disposables.push( - connection.onDidCloseTextDocument((event: DidCloseTextDocumentParams) => { - this.notifyDidCloseTextDocument(event); - }) - ); - disposables.push( - connection.onWillSaveTextDocument((event: WillSaveTextDocumentParams) => { - this.notifyWillSaveTextDocument(event); - }) - ); - disposables.push( - connection.onWillSaveTextDocumentWaitUntil((event: WillSaveTextDocumentParams, token: CancellationToken) => - this.notifyWillSaveTextDocumentWaitUntil(event, token) - ) - ); - disposables.push( - connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => { - this.notifyDidSaveTextDocument(event); - }) - ); - return Disposable.create(() => { - disposables.forEach(disposable => disposable.dispose()); - }); - } - - public notifyDidChangeTextDocument(event: DidChangeTextDocumentParams): void { - const td = event.textDocument; - const changes = event.contentChanges; - if (changes.length === 0) { - return; - } - - const { version } = td; - // eslint-disable-next-line no-null/no-null - if (version === null || version === undefined) { - throw new Error(`Received document change event for ${td.uri} without valid version identifier`); - } - - let syncedDocument = this.__syncedDocuments.get(td.uri); - if (syncedDocument !== undefined) { - if (syncedDocument.version >= td.version) { - console.log(`Skip update of document ${td.uri} as local version is newer (${syncedDocument.version} >= ${td.version})`); + protected __clientDocuments = new Map>(); + protected __changeHistory = new Map(); + + public constructor(protected configuration: TextDocumentsConfiguration, protected logger: ClientLogger) { + super(configuration); + } + + protected get __syncedDocuments(): Map { + return this['_syncedDocuments']; + } + + protected get __onDidChangeContent(): Emitter> { + return this['_onDidChangeContent']; + } + + override get onDidChangeContent(): Event> { + return this.__onDidChangeContent.event; + } + + protected get __onDidOpen(): Emitter> { + return this['_onDidOpen']; + } + + override get onDidOpen(): Event> { + return this.__onDidOpen.event; + } + + protected get __onDidClose(): Emitter> { + return this['_onDidClose']; + } + + override get onDidClose(): Event> { + return this.__onDidClose.event; + } + + protected get __onDidSave(): Emitter> { + return this['_onDidSave']; + } + + override get onDidSave(): Event> { + return this['__onDidSave'].event; + } + + protected get __onWillSave(): Emitter> { + return this['_onWillSave']; + } + + protected get __willSaveWaitUntil(): RequestHandler, TextEdit[], void> | undefined { + return this['_willSaveWaitUntil']; + } + + public override listen(connection: any): Disposable { + (connection).__textDocumentSync = TextDocumentSyncKind.Incremental; + const disposables: Disposable[] = []; + disposables.push( + connection.onDidOpenTextDocument((event: DidOpenTextDocumentParams) => { + this.notifyDidOpenTextDocument(event); + }) + ); + disposables.push( + connection.onDidChangeTextDocument((event: DidChangeTextDocumentParams) => { + this.notifyDidChangeTextDocument(event); + }) + ); + disposables.push( + connection.onDidCloseTextDocument((event: DidCloseTextDocumentParams) => { + this.notifyDidCloseTextDocument(event); + }) + ); + disposables.push( + connection.onWillSaveTextDocument((event: WillSaveTextDocumentParams) => { + this.notifyWillSaveTextDocument(event); + }) + ); + disposables.push( + connection.onWillSaveTextDocumentWaitUntil((event: WillSaveTextDocumentParams, token: CancellationToken) => + this.notifyWillSaveTextDocumentWaitUntil(event, token) + ) + ); + disposables.push( + connection.onDidSaveTextDocument((event: DidSaveTextDocumentParams) => { + this.notifyDidSaveTextDocument(event); + }) + ); + return Disposable.create(() => { + disposables.forEach(disposable => disposable.dispose()); + }); + } + + public notifyDidChangeTextDocument(event: DidChangeTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + const td = event.textDocument; + const changes = event.contentChanges; + if (changes.length === 0) { + return; + } + + const { version } = td; + // eslint-disable-next-line no-null/no-null + if (version === null || version === undefined) { + throw new Error(`Received document change event for ${td.uri} without valid version identifier`); + } + + let document = this.__syncedDocuments.get(td.uri); + if (document !== undefined) { + if (document.version >= td.version) { + this.log(document.uri, `Update is out of date (${document.version} >= ${td.version}): Ignore update by ${clientId}`); + return; + } + document = this.configuration.update(document, changes, version); + this.__syncedDocuments.set(td.uri, document); + const changeHistory = this.__changeHistory.get(td.uri) || []; + changeHistory[td.version] = clientId; + this.__changeHistory.set(td.uri, changeHistory); + this.log(document.uri, `Update to version ${td.version} by ${clientId}`); + this.__onDidChangeContent.fire(Object.freeze({ document, clientId })); + } + } + + public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + if (!this.isOpenInClient(event.textDocument.uri, clientId)) { + return; + } + this.__clientDocuments.get(event.textDocument.uri)?.delete(clientId); + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined) { + this.log(syncedDocument.uri, `Closed synced document: ${syncedDocument.version} by ${clientId}`); + this.__onDidClose.fire(Object.freeze({ document: syncedDocument, clientId })); + + if (!this.__clientDocuments.get(event.textDocument.uri)?.size) { + // last client closed the document, delete sync state + this.log(syncedDocument.uri, `Remove synced document: ${syncedDocument.version} (no client left)`); + this.__syncedDocuments.delete(event.textDocument.uri); + this.__changeHistory.delete(event.textDocument.uri); + } + } + } + + public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void { + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined) { + this.__onWillSave.fire(Object.freeze({ document: syncedDocument, reason: event.reason })); + } + } + + public notifyWillSaveTextDocumentWaitUntil( + event: WillSaveTextDocumentParams, + token: CancellationToken + ): HandlerResult { + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined && this.__willSaveWaitUntil) { + return this.__willSaveWaitUntil(Object.freeze({ document: syncedDocument, reason: event.reason }), token); + } else { + return []; + } + } + + public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); + if (syncedDocument !== undefined) { + this.log(syncedDocument.uri, `Saved synced document: ${syncedDocument.version} by ${clientId}`); + this.__onDidSave.fire(Object.freeze({ document: syncedDocument, clientId })); + } + } + + public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, clientId = LANGUAGE_CLIENT_ID): void { + if (this.isOpenInClient(event.textDocument.uri, clientId)) { return; - } - syncedDocument = this.configuration.update(syncedDocument, changes, version); - this.__syncedDocuments.set(td.uri, syncedDocument); - this.__onDidChangeContent.fire(Object.freeze({ document: syncedDocument })); - } - } - - public notifyDidCloseTextDocument(event: DidCloseTextDocumentParams): void { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined) { - this.__syncedDocuments.delete(event.textDocument.uri); - this.__onDidClose.fire(Object.freeze({ document: syncedDocument })); - } - } - - public notifyWillSaveTextDocument(event: WillSaveTextDocumentParams): void { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined) { - this.__onWillSave.fire(Object.freeze({ document: syncedDocument, reason: event.reason })); - } - } - - public notifyWillSaveTextDocumentWaitUntil( - event: WillSaveTextDocumentParams, - token: CancellationToken - ): HandlerResult { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined && this.__willSaveWaitUntil) { - return this.__willSaveWaitUntil(Object.freeze({ document: syncedDocument, reason: event.reason }), token); - } else { - return []; - } - } - - public notifyDidSaveTextDocument(event: DidSaveTextDocumentParams): void { - const syncedDocument = this.__syncedDocuments.get(event.textDocument.uri); - if (syncedDocument !== undefined) { - this.__onDidSave.fire(Object.freeze({ document: syncedDocument })); - } - } - - public notifyDidOpenTextDocument(event: DidOpenTextDocumentParams, openedInTextEditor = true): void { - const td = event.textDocument; - const document = this.configuration.create(td.uri, td.languageId, td.version, td.text); - const wasOpenInTextEditor = this.isOpenInTextEditor(td.uri); - this.__syncedDocuments.set(td.uri, document); - this.markOpenInTextEditor(td.uri, wasOpenInTextEditor || openedInTextEditor); - const toFire = Object.freeze({ document }); - this.__onDidOpen.fire(toFire); - this.__onDidChangeContent.fire(toFire); - } - - isOpenInTextEditor(uri: string): boolean { - return !!(this.__syncedDocuments.get(uri) as any | undefined)?.[OPENED_IN_TEXT_EDITOR_PROP]; - } - - protected markOpenInTextEditor(uri: string, open: boolean): void { - const document = this.__syncedDocuments.get(uri); - if (document) { - (document as any)[OPENED_IN_TEXT_EDITOR_PROP] = open; - } - } + } + const td = event.textDocument; + let document = this.__syncedDocuments.get(td.uri); + const clients = this.__clientDocuments.get(td.uri) || new Set(); + clients.add(clientId); + this.__clientDocuments.set(td.uri, clients); + if (!document) { + // no synced document yet, create new one + this.log(td.uri, `Opened new document: ${td.version} by ${clientId}`); + document = this.configuration.create(td.uri, td.languageId, td.version, td.text); + this.__syncedDocuments.set(td.uri, document); + this.__changeHistory.set(td.uri, [clientId]); + const toFire = Object.freeze({ document, clientId }); + this.__onDidOpen.fire(toFire); + this.__onDidChangeContent.fire(toFire); + } else { + // document was already synced, so we just change a content change + this.log(td.uri, `Opened synced document: ${td.version} by ${clientId}`); + const toFire = Object.freeze({ document, clientId }); + this.__onDidChangeContent.fire(toFire); + } + } + + getChangeSource(uri: string, version: number): string | undefined { + return this.__changeHistory.get(uri)?.[version]; + } + + isOpen(uri: string): boolean { + return this.__syncedDocuments.has(uri); + } + + isOpenInClient(uri: string, client: string): boolean { + return !!this.__clientDocuments.get(uri)?.has(client); + } + + isOpenInLanguageClient(uri: string): boolean { + return this.isOpenInClient(uri, LANGUAGE_CLIENT_ID); + } + + protected log(uri: string, message: string): void { + const full = URI.parse(uri); + this.logger.info(`[Documents][${basename(full.fsPath)}] ${message}`); + } } diff --git a/packages/form-client/src/browser/form-editor-open-handler.ts b/packages/form-client/src/browser/form-editor-open-handler.ts index 0075dbd6..9e2e3704 100644 --- a/packages/form-client/src/browser/form-editor-open-handler.ts +++ b/packages/form-client/src/browser/form-editor-open-handler.ts @@ -3,24 +3,24 @@ ********************************************************************************/ import { MaybePromise, nls } from '@theia/core'; -import { NavigatableWidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { NavigatableWidgetOpenHandler } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { injectable } from '@theia/core/shared/inversify'; import { FormEditorWidget } from './form-editor-widget'; @injectable() export class FormEditorOpenHandler extends NavigatableWidgetOpenHandler { - static ID = 'form-editor-opener'; + static ID = 'form-editor-opener'; - readonly id = FormEditorOpenHandler.ID; // must match the id of the widget factory - readonly label = nls.localize('form-client/form-editor', 'Form Editor'); + readonly id = FormEditorOpenHandler.ID; // must match the id of the widget factory + readonly label = nls.localize('form-client/form-editor', 'Form Editor'); - canHandle(uri: URI, options?: WidgetOpenerOptions): MaybePromise { - return uri.path.ext === '.cm' ? 1 : -1; - } + canHandle(uri: URI): MaybePromise { + return uri.path.ext === '.cm' ? 1 : -1; + } } export function createFormEditorId(uri: URI, counter?: number): string { - // ensure we create a unique ID - return FormEditorOpenHandler.ID + `:${uri.toString()}` + (counter !== undefined ? `:${counter}` : ''); + // ensure we create a unique ID + return FormEditorOpenHandler.ID + `:${uri.toString()}` + (counter !== undefined ? `:${counter}` : ''); } diff --git a/packages/form-client/src/browser/form-editor-widget.tsx b/packages/form-client/src/browser/form-editor-widget.tsx index 4589690a..bd20c493 100644 --- a/packages/form-client/src/browser/form-editor-widget.tsx +++ b/packages/form-client/src/browser/form-editor-widget.tsx @@ -19,6 +19,8 @@ export interface FormEditorWidgetOptions extends NavigatableWidgetOptions { id: string; } +const FORM_CLIENT_ID = 'form-client'; + @injectable() export class FormEditorWidget extends ReactWidget implements NavigatableWidget, Saveable { dirty = false; @@ -47,9 +49,9 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, this.getResourceUri = this.getResourceUri.bind(this); this.loadModel(); - this.formClient.onUpdate(document => { - if (document.uri === this.getResourceUri().toString()) { - this.modelUpdated(document.model); + this.formClient.onUpdate(event => { + if (event.sourceClientId !== FORM_CLIENT_ID && event.uri === this.getResourceUri().toString()) { + this.modelUpdated(event.model); } }); } @@ -57,8 +59,7 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, protected async loadModel(): Promise { try { const uri = this.getResourceUri().toString(); - await this.modelService.open(uri); - const model = await this.modelService.request(uri); + const model = await this.modelService.open({ uri, clientId: FORM_CLIENT_ID }); if (model) { this.syncedModel = model; } @@ -75,13 +76,13 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, } this.setDirty(false); - await this.modelService.save(this.getResourceUri().toString(), this.syncedModel); + await this.modelService.save({ uri: this.getResourceUri().toString(), model: this.syncedModel, clientId: FORM_CLIENT_ID }); } protected updateModel = debounce((model: CrossModelRoot) => { if (!deepEqual(this.syncedModel, model)) { this.syncedModel = model; - this.modelService.update(this.getResourceUri().toString(), model); + this.modelService.update({ uri: this.getResourceUri().toString(), model, clientId: FORM_CLIENT_ID }); } }, 200); @@ -93,7 +94,7 @@ export class FormEditorWidget extends ReactWidget implements NavigatableWidget, } override close(): void { - this.modelService.close(this.getResourceUri().toString()); + this.modelService.close({ uri: this.getResourceUri().toString(), clientId: FORM_CLIENT_ID }); super.close(); } diff --git a/packages/model-service/src/browser/model-service-client.ts b/packages/model-service/src/browser/model-service-client.ts index fafa7a1b..a1f545e4 100644 --- a/packages/model-service/src/browser/model-service-client.ts +++ b/packages/model-service/src/browser/model-service-client.ts @@ -2,26 +2,21 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ +import { CrossModelRoot, ModelUpdatedEvent } from '@crossbreeze/protocol'; import { Emitter } from '@theia/core'; import { injectable } from '@theia/core/shared/inversify'; import { ModelServiceClient } from '../common/model-service-rpc'; -import { CrossModelRoot } from '@crossbreeze/protocol'; - -export interface ModelDocument { - uri: string; - model: CrossModelRoot; -} @injectable() export class ModelServiceClientImpl implements ModelServiceClient { - protected onUpdateEmitter = new Emitter(); + protected onUpdateEmitter = new Emitter>(); onUpdate = this.onUpdateEmitter.event; async getName(): Promise { return 'ModelServiceClient'; } - async updateModel(uri: string, model: CrossModelRoot): Promise { - this.onUpdateEmitter.fire({ uri, model }); + async updateModel(event: ModelUpdatedEvent): Promise { + this.onUpdateEmitter.fire(event); } } diff --git a/packages/model-service/src/common/model-service-rpc.ts b/packages/model-service/src/common/model-service-rpc.ts index abadd45c..4a4ec8a3 100644 --- a/packages/model-service/src/common/model-service-rpc.ts +++ b/packages/model-service/src/common/model-service-rpc.ts @@ -2,9 +2,16 @@ * Copyright (c) 2023 CrossBreeze. ********************************************************************************/ -import { CrossModelRoot, DiagramNodeEntity } from '@crossbreeze/protocol'; -import { JsonRpcServer, Event } from '@theia/core'; -import { ModelDocument } from '../browser'; +import { + CloseModelArgs, + CrossModelRoot, + DiagramNodeEntity, + ModelUpdatedEvent, + OpenModelArgs, + SaveModelArgs, + UpdateModelArgs +} from '@crossbreeze/protocol'; +import { Event, JsonRpcServer } from '@theia/core'; /** Path used to communicate between the Theia frontend and backend */ export const MODEL_SERVICE_PATH = '/services/model-service'; @@ -14,17 +21,17 @@ export const MODEL_SERVICE_PATH = '/services/model-service'; */ export const ModelService = Symbol('ModelService'); export interface ModelService extends JsonRpcServer { - open(uri: string): Promise; - close(uri: string): Promise; + open(args: OpenModelArgs): Promise; + close(args: CloseModelArgs): Promise; request(uri: string): Promise; requestDiagramNodeEntityModel(uri: string, id: string): Promise; - update(uri: string, model: CrossModelRoot): Promise; - save(uri: string, model: CrossModelRoot): Promise; + update(args: UpdateModelArgs): Promise; + save(args: SaveModelArgs): Promise; } export const ModelServiceClient = Symbol('ModelServiceClient'); export interface ModelServiceClient { getName(): Promise; - updateModel(uri: string, model: CrossModelRoot): Promise; - onUpdate: Event; + updateModel(args: ModelUpdatedEvent): Promise; + onUpdate: Event>; } diff --git a/packages/model-service/src/node/model-service.ts b/packages/model-service/src/node/model-service.ts index 8e81a65d..3a12f349 100644 --- a/packages/model-service/src/node/model-service.ts +++ b/packages/model-service/src/node/model-service.ts @@ -4,17 +4,21 @@ import { waitForTemporaryFileContent } from '@crossbreeze/core/lib/node'; import { CloseModel, + CloseModelArgs, CrossModelRoot, DiagramNodeEntity, MODELSERVER_PORT_FILE, OnSave, OnUpdated, OpenModel, + OpenModelArgs, PORT_FOLDER, RequestModel, RequestModelDiagramNode, SaveModel, - UpdateModel + SaveModelArgs, + UpdateModel, + UpdateModelArgs } from '@crossbreeze/protocol'; import { URI } from '@theia/core'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -85,14 +89,14 @@ export class ModelServiceImpl implements ModelService, BackendApplicationContrib return Number.parseInt(port, 10); } - async open(uri: string): Promise { + async open(args: OpenModelArgs): Promise { await this.initializeServer(); - await this.connection.sendRequest(OpenModel, uri); + return this.connection.sendRequest(OpenModel, args); } - async close(uri: string): Promise { + async close(args: CloseModelArgs): Promise { await this.initializeServer(); - await this.connection.sendRequest(CloseModel, uri); + await this.connection.sendRequest(CloseModel, args); } async request(uri: string): Promise { @@ -100,14 +104,14 @@ export class ModelServiceImpl implements ModelService, BackendApplicationContrib return this.connection.sendRequest(RequestModel, uri); } - async update(uri: string, model: CrossModelRoot): Promise { + async update(args: UpdateModelArgs): Promise { await this.initializeServer(); - return this.connection.sendRequest(UpdateModel, uri, model); + return this.connection.sendRequest(UpdateModel, args); } - async save(uri: string, model: CrossModelRoot): Promise { + async save(args: SaveModelArgs): Promise { await this.initializeServer(); - return this.connection.sendRequest(SaveModel, uri, model); + return this.connection.sendRequest(SaveModel, args); } dispose(): void { @@ -123,11 +127,11 @@ export class ModelServiceImpl implements ModelService, BackendApplicationContrib } setUpListeners(): void { - this.connection.onNotification(OnSave, (uri, model) => { - this.client?.updateModel(uri, model); + this.connection.onNotification(OnSave, event => { + this.client?.updateModel(event); }); - this.connection.onNotification(OnUpdated, (uri, model) => { - this.client?.updateModel(uri, model); + this.connection.onNotification(OnUpdated, event => { + this.client?.updateModel(event); }); } diff --git a/packages/property-view/src/browser/model-data-service.ts b/packages/property-view/src/browser/model-data-service.ts new file mode 100644 index 00000000..c27b75d7 --- /dev/null +++ b/packages/property-view/src/browser/model-data-service.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (c) 2023 CrossBreeze. + ********************************************************************************/ + +import { ModelService } from '@crossbreeze/model-service/lib/common'; +import { CrossModelRoot, DiagramNodeEntity } from '@crossbreeze/protocol'; +import { GlspSelection } from '@eclipse-glsp/theia-integration'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; + +export const PROPERTY_CLIENT_ID = 'property-view-client'; + +@injectable() +export class ModelDataService implements PropertyDataService { + id = 'model-property-data-service'; + label = 'ModelPropertyDataService'; + currentUri?: string; + + @inject(ModelService) protected modelService: ModelService; + + canHandleSelection(selection: GlspSelection | undefined): number { + const canHandle = GlspSelection.is(selection) ? 1 : 0; + + // Close the previous file if there is a new selection the property view can not handle + if (canHandle === 0 && this.currentUri) { + this.modelService.close({ uri: this.currentUri, clientId: PROPERTY_CLIENT_ID }); + } + + return canHandle; + } + + protected async closeCurrentModel(): Promise { + if (this.currentUri) { + return this.modelService.close({ uri: this.currentUri, clientId: PROPERTY_CLIENT_ID }); + } + this.currentUri = undefined; + } + + protected async openCurrentModel(): Promise { + if (this.currentUri) { + return this.modelService.open({ uri: this.currentUri, clientId: PROPERTY_CLIENT_ID }); + } + return undefined; + } + + protected async getSelectedEntity(selection: GlspSelection | undefined): Promise { + if (selection && GlspSelection.is(selection) && selection.sourceUri && selection.selectedElementsIDs.length !== 0) { + return this.modelService.requestDiagramNodeEntityModel(selection.sourceUri, selection.selectedElementsIDs[0]); + } + return undefined; + } + + async providePropertyData(selection: GlspSelection | undefined): Promise { + const entity = await this.getSelectedEntity(selection); + if (!entity) { + this.closeCurrentModel(); + return undefined; + } + const newUri = entity.uri; + if (newUri !== this.currentUri) { + await this.closeCurrentModel(); + } + this.currentUri = newUri; + await this.openCurrentModel(); + return entity; + } +} diff --git a/packages/property-view/src/browser/model-property-widget.tsx b/packages/property-view/src/browser/model-property-widget.tsx index 93cd38f5..e68a7b75 100644 --- a/packages/property-view/src/browser/model-property-widget.tsx +++ b/packages/property-view/src/browser/model-property-widget.tsx @@ -13,6 +13,7 @@ import { IActionDispatcher } from '@eclipse-glsp/client'; import { GLSPDiagramWidget, GlspSelection } from '@eclipse-glsp/theia-integration'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { inject, injectable } from '@theia/core/shared/inversify'; +import { PROPERTY_CLIENT_ID } from './model-data-service'; import { App } from './react-components/App'; @injectable() @@ -60,7 +61,7 @@ export class ModelPropertyWidget extends ReactWidget implements PropertyViewCont if (this.model === undefined || this.uri === undefined) { throw new Error('Cannot save undefined model'); } - this.modelService.update(this.uri, this.model); + this.modelService.update({ uri: this.uri, model: this.model, clientId: PROPERTY_CLIENT_ID }); } protected async updateModel(model: CrossModelRoot): Promise { diff --git a/packages/property-view/src/browser/property-view-frontend-module.ts b/packages/property-view/src/browser/property-view-frontend-module.ts index ee77278c..6923feb5 100644 --- a/packages/property-view/src/browser/property-view-frontend-module.ts +++ b/packages/property-view/src/browser/property-view-frontend-module.ts @@ -3,12 +3,12 @@ ********************************************************************************/ import { ContainerModule } from '@theia/core/shared/inversify'; -import { PropertyViewWidgetProvider } from '@theia/property-view/lib/browser/property-view-widget-provider'; -import { ModelPropertyWidgetProvider } from './model-property-widget-provider'; import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; -import { ModelDataService } from '../common/model-data-service'; +import { PropertyViewWidgetProvider } from '@theia/property-view/lib/browser/property-view-widget-provider'; import '../../src/style/property-view.css'; +import { ModelDataService } from './model-data-service'; import { ModelPropertyWidget } from './model-property-widget'; +import { ModelPropertyWidgetProvider } from './model-property-widget-provider'; export default new ContainerModule((bind, _unbind, _isBound, rebind) => { // To make the property widget working diff --git a/packages/property-view/src/common/model-data-service.ts b/packages/property-view/src/common/model-data-service.ts deleted file mode 100644 index 89173aca..00000000 --- a/packages/property-view/src/common/model-data-service.ts +++ /dev/null @@ -1,49 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2023 CrossBreeze. - ********************************************************************************/ - -import { ModelService } from '@crossbreeze/model-service/lib/common'; -import { DiagramNodeEntity } from '@crossbreeze/protocol'; -import { GlspSelection } from '@eclipse-glsp/theia-integration'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import { PropertyDataService } from '@theia/property-view/lib/browser/property-data-service'; -@injectable() -export class ModelDataService implements PropertyDataService { - id = 'model-property-data-service'; - label = 'ModelPropertyDataService'; - currentUri: string; - - @inject(ModelService) protected modelService: ModelService; - - canHandleSelection(selection: GlspSelection | undefined): number { - const canHandle = GlspSelection.is(selection) ? 1 : 0; - - // Close the previous file if there is a new selection the property view can not handle - if (canHandle === 0 && this.currentUri !== '') { - this.modelService.close(this.currentUri); - } - - return canHandle; - } - - async providePropertyData(selection: GlspSelection | undefined): Promise { - if (selection && GlspSelection.is(selection) && selection.sourceUri && selection.selectedElementsIDs.length !== 0) { - const entity: DiagramNodeEntity | undefined = await this.modelService.requestDiagramNodeEntityModel( - selection.sourceUri, - selection.selectedElementsIDs[0] - ); - - if (entity) { - if (this.currentUri && this.currentUri !== entity.uri) { - await this.modelService.close(this.currentUri); - this.currentUri = entity.uri; - await this.modelService.open(this.currentUri); - } - - return entity; - } - } - - return undefined; - } -} diff --git a/packages/protocol/src/model-service/protocol.ts b/packages/protocol/src/model-service/protocol.ts index 3ce56a2d..c2f4b6af 100644 --- a/packages/protocol/src/model-service/protocol.ts +++ b/packages/protocol/src/model-service/protocol.ts @@ -52,13 +52,44 @@ export function isDiagramNodeEntity(model?: any): model is DiagramNodeEntity { return !!model && model.uri && model.model && isCrossModelRoot(model.model); } -export const OpenModel = new rpc.RequestType1('server/open'); -export const CloseModel = new rpc.RequestType1('server/close'); +export interface ClientModelArgs { + uri: string; + clientId: string; +} + +export interface OpenModelArgs extends ClientModelArgs { + languageId?: string; +} + +export interface CloseModelArgs extends ClientModelArgs {} + +export interface UpdateModelArgs extends ClientModelArgs { + model: T | string; +} + +export interface SaveModelArgs extends ClientModelArgs { + model: T | string; +} + +export interface ModelUpdatedEvent { + uri: string; + model: T; + sourceClientId: string; +} + +export interface ModelSavedEvent { + uri: string; + model: T; + sourceClientId: string; +} + +export const OpenModel = new rpc.RequestType1('server/open'); +export const CloseModel = new rpc.RequestType1('server/close'); export const RequestModel = new rpc.RequestType1('server/request'); export const RequestModelDiagramNode = new rpc.RequestType2( 'server/requestModelDiagramNode' ); -export const UpdateModel = new rpc.RequestType2('server/update'); -export const SaveModel = new rpc.RequestType2('server/save'); -export const OnSave = new rpc.NotificationType2('server/onSave'); -export const OnUpdated = new rpc.NotificationType2('server/onUpdated'); +export const UpdateModel = new rpc.RequestType1, CrossModelRoot, void>('server/update'); +export const SaveModel = new rpc.RequestType1, void, void>('server/save'); +export const OnSave = new rpc.NotificationType1>('server/onSave'); +export const OnUpdated = new rpc.NotificationType1>('server/onUpdated');