@eclipse-glsp/vscode-integration
Version:
Glue code to integrate GLSP diagrams in VSCode extensions (extension part)
507 lines • 25 kB
JavaScript
"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