UNPKG

@eclipse-glsp/vscode-integration

Version:

Glue code to integrate GLSP diagrams in VSCode extensions (extension part)

507 lines 25 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.GlspVscodeConnector = exports.MessageOrigin = void 0; /******************************************************************************** * Copyright (c) 2021-2024 EclipseSource and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0. * * This Source Code may also be made available under the following Secondary * Licenses when the conditions for such availability set forth in the Eclipse * Public License v. 2.0 are satisfied: GNU General Public License, version 2 * with the GNU Classpath Exception which is available at * https://www.gnu.org/software/classpath/license.html. * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ const protocol_1 = require("@eclipse-glsp/protocol"); const vscode = require("vscode"); const vscode_messenger_1 = require("vscode-messenger"); // eslint-disable-next-line no-shadow var MessageOrigin; (function (MessageOrigin) { MessageOrigin[MessageOrigin["CLIENT"] = 0] = "CLIENT"; MessageOrigin[MessageOrigin["SERVER"] = 1] = "SERVER"; })(MessageOrigin || (exports.MessageOrigin = MessageOrigin = {})); /** * The `GlspVscodeConnector` acts as the bridge between GLSP-Clients and the GLSP-Server * and is at the core of the Glsp-VSCode integration. * * It works by being providing a server that implements the `GlspVscodeServer` * interface and registering clients using the `GlspVscodeConnector.registerClient` * function. Messages sent between the clients and the server are then intercepted * by the connector to provide functionality based on the content of the messages. * * Messages can be intercepted using the interceptor properties in the options * argument. * * Selection updates can be listened to using the `onSelectionUpdate` property. */ class GlspVscodeConnector { /** * A subscribable event which fires when a document changed. The event body * will contain that document. Use this event for the `onDidChangeCustomDocument` * on your implementation of the `CustomEditorProvider`. */ get onDidChangeCustomDocument() { return this.onDidChangeCustomDocumentEventEmitter.event; } constructor(options) { /** Maps clientId to corresponding GlspVscodeClient. */ this.clientMap = new Map(); /** Maps Documents to corresponding clientId. */ this.documentMap = new Map(); /** Maps clientId to selected elementIDs for that client. */ this.clientSelectionMap = new Map(); /** Maps clientId to ongoing progress (reporters) for that client */ this.clientProgressMap = new Map(); this.diagnostics = vscode.languages.createDiagnosticCollection(); this.selectionUpdateEmitter = new vscode.EventEmitter(); this.onDocumentSavedEmitter = new vscode.EventEmitter(); this.onDidChangeCustomDocumentEventEmitter = new vscode.EventEmitter(); this.disposables = []; this.messenger = new vscode_messenger_1.Messenger(); // Create default options this.options = { logging: false, onBeforeReceiveMessageFromClient: (message, callback) => { callback(message, true); }, onBeforeReceiveMessageFromServer: (message, callback) => { callback(message, true); }, onBeforePropagateMessageToClient: (_originalMessage, processedMessage) => processedMessage, onBeforePropagateMessageToServer: (_originalMessage, processedMessage) => processedMessage, ...options }; this.onSelectionUpdate = this.selectionUpdateEmitter.event; // Set up message listener for server const serverMessageListener = this.options.server.onServerMessage(message => { if (this.options.logging) { if (protocol_1.ActionMessage.is(message)) { console.log(`Server (${message.clientId}): ${message.action.kind}`, message.action); } else { console.log('Server (no action message):', message); } } // Run message through first user-provided interceptor (pre-receive) this.options.onBeforeReceiveMessageFromServer(message, (newMessage, shouldBeProcessedByConnector = true) => { const { processedMessage, messageChanged } = shouldBeProcessedByConnector ? this.processMessage(newMessage, MessageOrigin.SERVER) : { processedMessage: message, messageChanged: false }; // Run message through second user-provided interceptor (pre-send) - processed const filteredMessage = this.options.onBeforePropagateMessageToClient(newMessage, processedMessage, messageChanged); if (typeof filteredMessage !== 'undefined' && protocol_1.ActionMessage.is(filteredMessage)) { this.sendMessageToClient(filteredMessage.clientId, filteredMessage); } }); }); this.disposables.push(this.diagnostics, this.selectionUpdateEmitter, serverMessageListener); } /** * Register a client on the GLSP-VSCode connector. All communication will subsequently * run through the VSCode integration. Clients do not need to be unregistered * as they are automatically disposed of when the panel they belong to is closed. * * @param client The client to register. * */ async registerClient(client) { const toDispose = [ protocol_1.Disposable.create(() => { var _a; this.diagnostics.set(client.document.uri, undefined); // this clears the diagnostics for the file this.clientMap.delete(client.clientId); this.documentMap.delete(client.document); this.clientSelectionMap.delete(client.clientId); (_a = this.clientProgressMap.get(client.clientId)) === null || _a === void 0 ? void 0 : _a.forEach(reporter => reporter.deferred.resolve()); this.clientProgressMap.delete(client.clientId); }) ]; // Cleanup when client panel is closed const panelOnDisposeListener = client.webviewEndpoint.webviewPanel.onDidDispose(async () => { toDispose.forEach(disposable => disposable.dispose()); panelOnDisposeListener.dispose(); }); this.clientMap.set(client.clientId, client); this.documentMap.set(client.document, client.clientId); this.clientProgressMap.set(client.clientId, new Map()); toDispose.push(client.webviewEndpoint.onActionMessage(message => { if (this.options.logging) { if (protocol_1.ActionMessage.is(message)) { console.log(`Client (${message.clientId}): ${message.action.kind}`, message.action); } else { console.log('Client (no action message):', message); } } // Run message through first user-provided interceptor (pre-receive) this.options.onBeforeReceiveMessageFromClient(message, (newMessage, shouldBeProcessedByConnector = true) => { const { processedMessage, messageChanged } = shouldBeProcessedByConnector ? this.processMessage(newMessage, MessageOrigin.CLIENT) : { processedMessage: message, messageChanged: false }; const filteredMessage = this.options.onBeforePropagateMessageToServer(newMessage, processedMessage, messageChanged); if (typeof filteredMessage !== 'undefined') { this.options.server.onSendToServerEmitter.fire(filteredMessage); } }); })); toDispose.push(client.webviewEndpoint.webviewPanel.onDidChangeViewState(e => { if (e.webviewPanel.active) { this.selectionUpdateEmitter.fire(this.clientSelectionMap.get(client.clientId) || { selectedElementsIDs: [], deselectedElementsIDs: [] }); } })); // Initialize glsp client const glspClient = await this.options.server.glspClient; toDispose.push(client.webviewEndpoint.initialize(glspClient)); toDispose.unshift(protocol_1.Disposable.create(() => glspClient.disposeClientSession({ clientSessionId: client.clientId }))); } /** * Send message to registered client by id. * Note that this method does not consider server-handled actions. * If you want to send an action that is potentially handled by both sides, use {@link dispatchAction} instead. * * @param clientId Id of client. * @param message Message to send. */ sendMessageToClient(clientId, message) { const client = this.clientMap.get(clientId); if (client && protocol_1.ActionMessage.is(message)) { client.webviewEndpoint.sendMessage(message); } } /** * Send action to registered client by id. * * @param clientId Id of client. * @param action Action to send. * * @deprecated Use {@link dispatchAction} instead. */ sendActionToClient(clientId, action) { this.dispatchAction(action, clientId); } /** * Send an action to the client/panel that is currently focused. If no registered * panel is focused, the message will not be sent. * * @param action The action to send to the active client. * @deprecated Use {@link dispatchAction} instead. */ sendActionToActiveClient(action) { this.dispatchAction(action); } /** * Dispatches an action associated with the given client id. If no id is provided, * the action will be dispatched associated with the client of the active webview panel. * If no client id is passed and no registered panel is focused, the action will not be dispatched. * Dispatching an action will send the action to the client and/or server if * they can handle the action. * @param action The action to dispatch. * @param clientId The id of the client/session associated with the action. */ dispatchAction(action, clientId) { var _a, _b; const client = clientId ? this.clientMap.get(clientId) : this.getActiveClient(); if (!client) { console.warn('Could not dispatch action: No client found for clientId or no active client found.', action); return; } const message = { clientId: client.clientId, action }; if ((_a = client.webviewEndpoint.clientActions) === null || _a === void 0 ? void 0 : _a.includes(action.kind)) { client.webviewEndpoint.sendMessage(message); } if ((_b = client.webviewEndpoint.serverActions) === null || _b === void 0 ? void 0 : _b.includes(action.kind)) { this.options.server.onSendToServerEmitter.fire(message); } } /** * Returns the currently active {@link GlspVscodeClient} i.e. the client whose webview panel is currently focused. * If no registered panel is focused, the method will return `undefined`. * @returns The active client or `undefined` if no client is active. */ getActiveClient() { for (const client of this.clientMap.values()) { if (client.webviewEndpoint.webviewPanel.active) { return client; } } return undefined; } /** * Provides the functionality of the VSCode integration. * * Incoming messages (unless intercepted) will run through this function and * be acted upon by providing default functionality for VSCode. * * @param message The original received message. * @param origin The origin of the received message. * @returns An object containing the processed message and an indicator wether * the message was modified. */ processMessage(message, origin) { if (protocol_1.ActionMessage.is(message)) { const client = this.clientMap.get(message.clientId); // server message if (protocol_1.MessageAction.is(message.action)) { return this.handleMessageAction(message, client, origin); } // progress reporting if (protocol_1.StartProgressAction.is(message.action)) { return this.handleStartProgressAction(message, client, origin); } if (protocol_1.UpdateProgressAction.is(message.action)) { return this.handleUpdateProgressAction(message, client, origin); } if (protocol_1.EndProgressAction.is(message.action)) { return this.handleEndProgressAction(message, client, origin); } // Dirty state & save actions if (protocol_1.SetDirtyStateAction.is(message.action)) { return this.handleSetDirtyStateAction(message, client, origin); } // Diagnostic actions if (protocol_1.SetMarkersAction.is(message.action)) { return this.handleSetMarkersAction(message, client, origin); } // External targets action if (protocol_1.NavigateToExternalTargetAction.is(message.action)) { return this.handleNavigateToExternalTargetAction(message, client, origin); } // Selection action if (protocol_1.SelectAction.is(message.action)) { return this.handleSelectAction(message, client, origin); } // Export SVG action if (protocol_1.ExportSvgAction.is(message.action)) { return this.handleExportSvgAction(message, client, origin); } } // Propagate unchanged message return { processedMessage: message, messageChanged: false }; } handleMessageAction(message, _client, _origin) { if (message.action.severity === 'ERROR') { vscode.window.showErrorMessage(message.action.message); } else if (message.action.severity === 'WARNING') { vscode.window.showWarningMessage(message.action.message); } else if (message.action.severity === 'INFO') { vscode.window.showInformationMessage(message.action.message); } // Do not propagate action return { processedMessage: undefined, messageChanged: true }; } handleStartProgressAction(actionMessage, client, origin) { if (client) { const { progressId, title, message, percentage } = actionMessage.action; const initialPercentage = (percentage !== null && percentage !== void 0 ? percentage : -1) >= 0 ? percentage : undefined; const deferred = new protocol_1.Deferred(); const location = vscode.ProgressLocation.Notification; vscode.window.withProgress({ title, location }, progress => { const reporterId = this.progressReporterId(client, progressId); const progressReporters = this.clientProgressMap.get(client.clientId); progressReporters === null || progressReporters === void 0 ? void 0 : progressReporters.set(reporterId, { deferred, progress, currentPercentage: initialPercentage }); progress.report({ message, increment: percentage }); return deferred.promise; }); } // Do not propagate action return { processedMessage: undefined, messageChanged: true }; } handleUpdateProgressAction(actionMessage, client, origin) { var _a, _b; if (client) { const { progressId, message, percentage } = actionMessage.action; const reporterId = this.progressReporterId(client, progressId); const reporter = (_a = this.clientProgressMap.get(client.clientId)) === null || _a === void 0 ? void 0 : _a.get(reporterId); if (reporter) { const currentPercentage = (_b = reporter.currentPercentage) !== null && _b !== void 0 ? _b : 0; const newPercentage = (percentage !== null && percentage !== void 0 ? percentage : -1) >= 0 ? percentage : undefined; const increment = newPercentage ? newPercentage - currentPercentage : undefined; reporter.progress.report({ message, increment }); if (newPercentage) { reporter.currentPercentage = newPercentage; } } } // Do not propagate action return { processedMessage: undefined, messageChanged: true }; } handleEndProgressAction(actionMessage, client, origin) { if (client) { const { progressId } = actionMessage.action; const reporterId = this.progressReporterId(client, progressId); const progressReporters = this.clientProgressMap.get(client.clientId); const reporter = progressReporters === null || progressReporters === void 0 ? void 0 : progressReporters.get(reporterId); if (progressReporters && reporter) { reporter.deferred.resolve(); progressReporters.delete(reporterId); } } // Do not propagate action return { processedMessage: undefined, messageChanged: true }; } progressReporterId(client, progressId) { return `${client.clientId}_${progressId}`; } handleSetDirtyStateAction(message, client, _origin) { // The webview client cannot handle `SetDirtyStateAction`s. Avoid propagation const result = { processedMessage: message, messageChanged: false }; if (client) { const reason = message.action.reason; if (reason === 'undo' || reason === 'redo') { return result; } if (reason === 'save') { this.onDocumentSavedEmitter.fire(client.document); } else if (reason === 'operation' && message.action.isDirty) { this.onDidChangeCustomDocumentEventEmitter.fire({ document: client.document, undo: () => { this.dispatchAction(protocol_1.UndoAction.create(), client.clientId); }, redo: () => { this.dispatchAction(protocol_1.RedoAction.create(), client.clientId); } }); } else if (message.action.isDirty) { this.onDidChangeCustomDocumentEventEmitter.fire({ document: client.document }); } } return result; } handleSetMarkersAction(message, client, _origin) { if (client) { const severityMap = new Map(); severityMap.set('info', vscode.DiagnosticSeverity.Information); severityMap.set('warning', vscode.DiagnosticSeverity.Warning); severityMap.set('error', vscode.DiagnosticSeverity.Error); const updatedDiagnostics = message.action.markers.map(marker => new vscode.Diagnostic(new vscode.Range(0, 0, 0, 0), // Must have be defined as such - no workarounds marker.description, severityMap.get(marker.kind))); this.diagnostics.set(client.document.uri, updatedDiagnostics); } // Propagate unchanged message return { processedMessage: message, messageChanged: false }; } handleNavigateToExternalTargetAction(message, _client, _origin) { const SHOW_OPTIONS = 'jsonOpenerOptions'; const { uri, args } = message.action.target; let showOptions = { ...args }; // Give server the possibility to provide options through the `showOptions` field by providing a // stringified version of the `TextDocumentShowOptions` // See: https://code.visualstudio.com/api/references/vscode-api#TextDocumentShowOptions const showOptionsField = args === null || args === void 0 ? void 0 : args[SHOW_OPTIONS]; if (showOptionsField) { showOptions = { ...args, ...JSON.parse(showOptionsField.toString()) }; } vscode.window.showTextDocument(vscode.Uri.parse(uri), showOptions).then(() => undefined, // onFulfilled: Do nothing. () => undefined // onRejected: Do nothing - This is needed as error handling in case the navigationTarget does not exist. ); // Do not propagate action return { processedMessage: undefined, messageChanged: true }; } handleSelectAction(message, client, origin) { if (client) { this.clientSelectionMap.set(client.clientId, message.action); this.selectionUpdateEmitter.fire(message.action); } if (origin === MessageOrigin.CLIENT) { // eslint-disable-next-line max-len // Do not propagate action if it comes from client to avoid an infinite loop, as both, client and server will mirror the Selection action return { processedMessage: undefined, messageChanged: true }; } else { // Propagate unchanged message return { processedMessage: message, messageChanged: false }; } } handleExportSvgAction(message, _client, _origin) { vscode.window .showSaveDialog({ filters: { SVG: ['svg'] }, saveLabel: 'Export', title: 'Export as SVG' }) .then((uri) => { if (uri) { const content = new TextEncoder().encode(message.action.svg); vscode.workspace.fs.writeFile(uri, content).then(undefined, err => { if (err) { console.error(err); } }); } }, console.error); // Do not propagate action to avoid an infinite loop, as both, client and server will mirror the Export SVG action return { processedMessage: undefined, messageChanged: true }; } /** * Saves a document. Make sure to call this function in the `saveCustomDocument` * and `saveCustomDocumentAs` functions of your `CustomEditorProvider` implementation. * * @param document The document to save. * @param destination Optional parameter. When this parameter is provided the * file will instead be saved at this location. * @returns A promise that resolves when the file has been successfully saved. */ async saveDocument(document, destination) { const clientId = this.documentMap.get(document); if (clientId) { return new Promise(resolve => { const listener = this.onDocumentSavedEmitter.event(savedDocument => { if (document === savedDocument) { listener.dispose(); resolve(); } }); this.dispatchAction(protocol_1.SaveModelAction.create({ fileUri: destination === null || destination === void 0 ? void 0 : destination.path }), clientId); }); } else { if (this.options.logging) { console.error('Saving failed: Document not registered'); } throw new Error('Saving failed.'); } } /** * Reverts a document. Make sure to call this function in the `revertCustomDocument` * functions of your `CustomEditorProvider` implementation. * * @param document Document to revert. * @param diagramType Diagram type as it is configured on the server. * @returns A promise that resolves when the file has been successfully reverted. */ async revertDocument(document, diagramType) { const clientId = this.documentMap.get(document); if (clientId) { const client = this.clientMap.get(clientId); if (client === null || client === void 0 ? void 0 : client.webviewEndpoint.webviewPanel.active) { this.dispatchAction(protocol_1.RequestModelAction.create({ options: { sourceUri: document.uri.toString(), diagramType } }), clientId); } } else { if (this.options.logging) { console.error('Backup failed: Document not registered'); } throw new Error('Backup failed.'); } } dispose() { this.disposables.forEach(disposable => disposable.dispose()); } } exports.GlspVscodeConnector = GlspVscodeConnector; //# sourceMappingURL=glsp-vscode-connector.js.map