diff --git a/.vscode/settings.json b/.vscode/settings.json index ad71d61d3..44a01d9d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -372,5 +372,6 @@ "wxss", "xquery", "Zeek" - ] + ], + "discord.enabled": true } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 327a04593..949a0f219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "ace-linters": "^1.8.1", "autosize": "^6.0.1", "cordova": "12.0.0", "core-js": "^3.45.0", @@ -3342,6 +3343,17 @@ "node": ">= 0.6" } }, + "node_modules/ace-linters": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/ace-linters/-/ace-linters-1.8.1.tgz", + "integrity": "sha512-gA/go8RjNsSnN1zcgBVQa9Uk81E1bAUSgSC41Wp50axz0L4XipesyPHhL9qImMAg8AnAlFA+i0qJbM/bd2zCWQ==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -10415,6 +10427,22 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -10644,6 +10672,37 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/walk-up-path": { "version": "3.0.1", "license": "ISC" diff --git a/package.json b/package.json index c697af747..3e5a74d43 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "ace-linters": "^1.8.1", "autosize": "^6.0.1", "cordova": "12.0.0", "core-js": "^3.45.0", diff --git a/src/lib/acode.js b/src/lib/acode.js index ea024037e..c7d622c6a 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -44,6 +44,7 @@ import helpers from "utils/helpers"; import KeyboardEvent from "utils/keyboardEvent"; import Url from "utils/Url"; import constants from "./constants"; +import lspClient from "./lspClient"; export default class Acode { #modules = {}; @@ -119,6 +120,7 @@ export default class Acode { }, }; + this.define("lspClient", lspClient); this.define("Url", Url); this.define("page", Page); this.define("Color", Color); diff --git a/src/lib/lspClient.js b/src/lib/lspClient.js new file mode 100644 index 000000000..c1ac42dd3 --- /dev/null +++ b/src/lib/lspClient.js @@ -0,0 +1,384 @@ +import { AceLanguageClient } from "ace-linters/build/ace-language-client"; +import settings from "./settings"; + +export default class lspClient { + constructor(wsUrl, modes, initializationOptions = {}) { + this.wsUrl = wsUrl; + // Convert modes array to string if needed, or keep as string + this.modes = Array.isArray(modes) ? modes.join(",") : modes; + this.socket = null; + this.languageProvider = null; + this.registeredEditors = new Set(); + this.initializationOptions = initializationOptions; + this.isConnected = false; + } + + /** + * Connect to the LSP server + * @returns {Promise} + */ + async connect() { + if (this.isConnected) { + console.warn("LSP client is already connected"); + return; + } + + try { + // Create WebSocket connection + this.socket = new WebSocket(this.wsUrl); + + // Set up WebSocket event handlers + this.socket.onopen = () => { + console.log("LSP WebSocket connected"); + this.isConnected = true; + this.reconnectAttempts = 0; + this._initializeLanguageProvider(); + }; + + this.socket.onclose = (event) => { + console.log("LSP WebSocket disconnected", event); + this.isConnected = false; + }; + + this.socket.onerror = (error) => { + console.error("LSP WebSocket error:", error); + this.isConnected = false; + }; + + // Wait for connection to be established + await this._waitForConnection(); + } catch (error) { + console.error("Failed to connect to LSP server:", error); + throw error; + } + } + + /** + * Disconnect from the LSP server + */ + disconnect() { + if (!this.isConnected) { + console.warn("LSP client is not connected"); + return; + } + + try { + // Remove all registered editors + this.registeredEditors.forEach((editor) => { + this.removeEditor(editor); + }); + + // Clean up language provider + if (this.languageProvider) { + this.languageProvider.dispose?.(); + this.languageProvider = null; + } + + // Close WebSocket connection + if (this.socket) { + this.socket.close(); + this.socket = null; + } + + this.isConnected = false; + console.log("LSP client disconnected"); + } catch (error) { + console.error("Error during disconnect:", error); + } + } + + /** + * Add an editor to the language provider + * @param {Object} editor - Ace editor instance + * @returns {boolean} - Success status + * + * Note: this is not limited to a single editor tab + */ + addEditor(editor) { + if (!editor) { + console.error("Editor is required"); + return false; + } + + if (this.registeredEditors.has(editor)) { + console.warn("Editor is already registered"); + return true; + } + + if (!this.isConnected || !this.languageProvider) { + console.error("LSP client is not connected. Call connect() first."); + return false; + } + + try { + settings.update({ + showAnnotations: true, + }); + this.languageProvider.registerEditor(editor); + this.registeredEditors.add(editor); + + const session = editor.getSession(); + session.on("changeAnnotation", () => { + editor.renderer.updateBackMarkers(); + }); + console.log("Editor registered with LSP client"); + return true; + } catch (error) { + console.error("Failed to register editor:", error); + return false; + } + } + + /** + * Remove an editor from the language provider + * @param {Object} editor - Ace editor instance + * @returns {boolean} - Success status + */ + removeEditor(editor) { + if (!editor) { + console.error("Editor is required"); + return false; + } + + if (!this.registeredEditors.has(editor)) { + console.warn("Editor is not registered"); + return true; + } + + try { + // Unregister from language provider if available + if (this.languageProvider && this.languageProvider.unregisterEditor) { + this.languageProvider.unregisterEditor(editor); + } + + this.registeredEditors.delete(editor); + console.log("Editor unregistered from LSP client"); + return true; + } catch (error) { + console.error("Failed to unregister editor:", error); + return false; + } + } + + /** + * + * This method sends a request to the LSP server to change the current workspace folder. + * The server must have access to the specified `uri`. + * + * ⚠️ Note: + * - The LSP server must be running and connected before calling this method. + * - Android SAF (`content://`) URIs are not supported — use a file system–accessible path instead. + * + * @async + * @param {string} uri - The URI of the workspace folder to set (e.g., "file:///path/to/folder"). + * @returns {Promise} Resolves to `true` if the operation was successful, otherwise `false`. + */ + async setWorkspaceFolder(uri) { + if (!this.languageProvider) { + console.error( + "Language provider not initialized. Connect to the LSP server first.", + ); + return false; + } + + try { + await this.languageProvider.changeWorkspaceFolder(uri); + } catch (error) { + console.error("Error setting workspace folder:", error); + return false; + } + } + + /** + * Format the current document or selection + * @param {Object} editor - Ace editor instance + * @param {Object} [range] - Formatting range (optional) + * @returns {Promise} + */ + async formatDocument(editor, range) { + if (!this.isConnected || !this.languageProvider) return false; + + try { + await this.languageProvider.format(editor, range); + return true; + } catch (error) { + console.error("Formatting failed:", error); + return false; + } + } + + /** + * Get hover information at current cursor position + * @param {Object} editor - Ace editor instance + * @returns {Promise} + */ + async getHoverInfo(editor) { + if (!this.isConnected || !this.languageProvider) return null; + + const session = editor.getSession(); + const cursor = editor.getCursorPosition(); + + try { + return await this.languageProvider.doHover(session, cursor); + } catch (error) { + console.error("Hover request failed:", error); + return null; + } + } + + /** + * Go to definition of symbol at cursor + * @param {Object} editor - Ace editor instance + * @returns {Promise} Location of definition + */ + async goToDefinition(editor, uri) { + if (!this.isConnected || !this.languageProvider?.client) return null; + + const session = editor.getSession(); + const cursor = editor.getCursorPosition(); + + try { + return await this.languageProvider.sendRequest( + "textDocument/definition", + { + textDocument: { uri }, + position: { line: cursor.row, character: cursor.column }, + }, + ); + } catch (error) { + console.error("Go to definition failed:", error); + return null; + } + } + + /** + * Find references to symbol at cursor + * @param {Object} editor - Ace editor instance + * @returns {Promise} Reference locations + */ + async findReferences(editor, uri) { + if (!this.isConnected || !this.languageProvider?.client) return null; + + const session = editor.getSession(); + const cursor = editor.getCursorPosition(); + + try { + return await this.languageProvider.sendRequest( + "textDocument/references", + { + textDocument: { uri }, + position: { line: cursor.row, character: cursor.column }, + context: { includeDeclaration: true }, + }, + ); + } catch (error) { + console.error("Find references failed:", error); + return null; + } + } + + /** + * Get the current connection status + * @returns {boolean} + */ + isConnectedToServer() { + return ( + this.isConnected && + this.socket && + this.socket.readyState === WebSocket.OPEN + ); + } + + /** + * Get the list of registered editors + * @returns {Set} + */ + getRegisteredEditors() { + return new Set(this.registeredEditors); + } + + async sendRequest(method, params) { + return new Promise((resolve, reject) => { + try { + this.languageProvider.sendRequest( + "lspClient", + method, + params, + (result) => { + resolve(result); + }, + ); + } catch (err) { + reject(err); + } + }); + } + + async getDefinition(editor, uri) { + const result = await this.sendRequest("textDocument/definition", { + textDocument: { uri }, + position: { + line: editor.getCursorPosition().row, + character: editor.getCursorPosition().column, + }, + }); + + return result; + } + + // Private helper methods + + /** + * Initialize the language provider after connection is established + * @private + */ + _initializeLanguageProvider() { + const serverData = { + module: () => import("ace-linters/build/language-client"), + modes: this.modes, + type: "socket", + socket: this.socket, + serviceName: "lspClient", + initializationOptions: this.initializationOptions, + }; + + try { + this.languageProvider = AceLanguageClient.for( + serverData, + this.initializationOptions, + ); + console.log("Language provider initialized"); + } catch (error) { + console.error("Failed to initialize language provider:", error); + throw error; + } + } + + /** + * Wait for WebSocket connection to be established + * @private + * @returns {Promise} + */ + _waitForConnection() { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Connection timeout")); + }, 10000); // 10 second timeout + + if (this.socket.readyState === WebSocket.OPEN) { + clearTimeout(timeout); + resolve(); + } else { + this.socket.addEventListener("open", () => { + clearTimeout(timeout); + resolve(); + }); + + this.socket.addEventListener("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + } + }); + } +} diff --git a/src/styles/overrideAceStyle.scss b/src/styles/overrideAceStyle.scss index 7fb00cbeb..231a0b551 100644 --- a/src/styles/overrideAceStyle.scss +++ b/src/styles/overrideAceStyle.scss @@ -18,12 +18,19 @@ } .ace_tooltip { - background-color: rgb(255, 255, 255); - background-color: var(--secondary-color); - color: rgb(37, 37, 37); - color: var(--secondary-text-color); - max-width: 68%; + background-color: var(--popup-background-color) !important; + color: var(--popup-text-color) !important; + border: 1px solid var(--popup-border-color) !important; + border-radius: var(--popup-border-radius) !important; + box-shadow: 0 4px 12px var(--box-shadow-color) !important; + padding: 8px 12px; + font-size: 13px; + line-height: 1.4; + max-width: 400px; + min-width: 120px; white-space: pre-wrap; + word-wrap: break-word; + transition: opacity 0.2s ease-in-out; } main .ace_editor {