From d232d841af58886beb3d3b6cfb2ecd6f1b5287ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:37:12 +0000 Subject: [PATCH 1/3] Initial plan From 4f0ddef4cd72984f68c65709e19684e7ecc928be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:50:42 +0000 Subject: [PATCH 2/3] Integrate LuaLS with Monaco editor in Electron app Co-authored-by: kkerti <47832952+kkerti@users.noreply.github.com> --- build-scripts/download-lua-ls.js | 199 +++++ electron-builder-config.js | 9 + package.json | 9 +- src/electron/main.ts | 31 + src/electron/preload.ts | 3 + src/electron/src/lua-language-server.ts | 325 ++++++++ src/renderer/assets/lua/annotations/grid.lua | 742 +++++++++++++++++++ src/renderer/lib/lua-ls-client.ts | 630 ++++++++++++++++ src/renderer/lib/monaco.ts | 13 + 9 files changed, 1957 insertions(+), 4 deletions(-) create mode 100644 build-scripts/download-lua-ls.js create mode 100644 src/electron/src/lua-language-server.ts create mode 100644 src/renderer/assets/lua/annotations/grid.lua create mode 100644 src/renderer/lib/lua-ls-client.ts diff --git a/build-scripts/download-lua-ls.js b/build-scripts/download-lua-ls.js new file mode 100644 index 000000000..bf14d9a80 --- /dev/null +++ b/build-scripts/download-lua-ls.js @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * Downloads the appropriate lua-language-server binary for the current + * (or target) platform and extracts it into resources/lua-language-server/. + * + * Usage: + * node build-scripts/download-lua-ls.js + * node build-scripts/download-lua-ls.js --version 3.13.6 + * node build-scripts/download-lua-ls.js --platform win32 --arch x64 + * + * The script is intentionally self-contained (no extra npm deps beyond + * Node built-ins + node-fetch which is already a project dependency). + */ + +"use strict"; + +const https = require("https"); +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); + +// ── Configuration ──────────────────────────────────────────────────────────── + +const LUA_LS_VERSION = + parseArg("--version") || process.env.LUA_LS_VERSION || "3.13.6"; + +const platform = parseArg("--platform") || process.platform; // win32 | darwin | linux +const arch = parseArg("--arch") || process.arch; // x64 | arm64 + +const OUTPUT_DIR = path.resolve( + __dirname, + "..", + "resources", + "lua-language-server", +); + +// ── Platform → asset name mapping ─────────────────────────────────────────── + +/** Maps Node.js platform/arch to the GitHub release asset suffix. */ +function getAssetName(platform, arch, version) { + const platformMap = { + win32: { x64: `win32-x64` }, + darwin: { x64: `darwin-x64`, arm64: `darwin-arm64` }, + linux: { x64: `linux-x64` }, + }; + + const key = (platformMap[platform] || {})[arch]; + if (!key) { + throw new Error( + `Unsupported platform/arch combination: ${platform}/${arch}`, + ); + } + + const ext = platform === "win32" ? "zip" : "tar.gz"; + return `lua-language-server-${version}-${key}.${ext}`; +} + +/** Returns the download URL for the given version and asset name. */ +function getDownloadUrl(version, assetName) { + return `https://github.com/LuaLS/lua-language-server/releases/download/${version}/${assetName}`; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function parseArg(name) { + const idx = process.argv.indexOf(name); + return idx !== -1 ? process.argv[idx + 1] : null; +} + +function mkdirp(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +/** Follow HTTP redirects and return the final response. */ +function fetch(url) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith("https") ? https : http; + protocol.get(url, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + fetch(res.headers.location).then(resolve).catch(reject); + } else if (res.statusCode !== 200) { + reject( + new Error( + `HTTP ${res.statusCode} while downloading ${url}`, + ), + ); + } else { + resolve(res); + } + }).on("error", reject); + }); +} + +/** Download url → destPath, showing a simple progress counter. */ +async function download(url, destPath) { + console.log(` Downloading: ${url}`); + const res = await fetch(url); + const total = parseInt(res.headers["content-length"] || "0", 10); + let received = 0; + + return new Promise((resolve, reject) => { + const out = fs.createWriteStream(destPath); + res.on("data", (chunk) => { + received += chunk.length; + if (total > 0) { + const pct = Math.round((received / total) * 100); + process.stdout.write(`\r Progress: ${pct}%`); + } + }); + res.pipe(out); + out.on("finish", () => { + process.stdout.write("\n"); + resolve(); + }); + out.on("error", reject); + res.on("error", reject); + }); +} + +/** Extract a .tar.gz archive using the system tar command. */ +function extractTarGz(archivePath, destDir) { + mkdirp(destDir); + execSync(`tar -xzf "${archivePath}" -C "${destDir}"`); +} + +/** Extract a .zip archive using the system unzip / PowerShell. */ +function extractZip(archivePath, destDir) { + mkdirp(destDir); + if (process.platform === "win32") { + execSync( + `powershell -Command "Expand-Archive -Force '${archivePath}' '${destDir}'"`, + ); + } else { + execSync(`unzip -o "${archivePath}" -d "${destDir}"`); + } +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + console.log( + `\nlua-language-server downloader (v${LUA_LS_VERSION} ${platform}/${arch})\n`, + ); + + const assetName = getAssetName(platform, arch, LUA_LS_VERSION); + const url = getDownloadUrl(LUA_LS_VERSION, assetName); + + // Check if already present + const binaryName = + platform === "win32" + ? "lua-language-server.exe" + : "lua-language-server"; + const binaryPath = path.join(OUTPUT_DIR, "bin", binaryName); + + if (fs.existsSync(binaryPath)) { + console.log(` Already installed: ${binaryPath}`); + console.log(" Delete resources/lua-language-server/ to force re-download."); + return; + } + + // Download + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lua-ls-")); + const archivePath = path.join(tmpDir, assetName); + + try { + await download(url, archivePath); + + // Extract into OUTPUT_DIR + console.log(` Extracting to: ${OUTPUT_DIR}`); + if (archivePath.endsWith(".tar.gz")) { + extractTarGz(archivePath, OUTPUT_DIR); + } else { + extractZip(archivePath, OUTPUT_DIR); + } + + if (!fs.existsSync(binaryPath)) { + throw new Error( + `Extraction succeeded but binary not found at: ${binaryPath}`, + ); + } + + // Make executable on POSIX + if (platform !== "win32") { + fs.chmodSync(binaryPath, 0o755); + } + + console.log(` ✓ lua-language-server ready: ${binaryPath}`); + } finally { + // Clean up temp dir + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error("\n✗ Failed to download lua-language-server:", err.message); + process.exit(1); +}); diff --git a/electron-builder-config.js b/electron-builder-config.js index 384a88b0e..595ba7dc7 100644 --- a/electron-builder-config.js +++ b/electron-builder-config.js @@ -32,6 +32,15 @@ const config = { from: "src/renderer/assets/**/*", to: "assets", }, + { + // lua-language-server binary — downloaded by `node build-scripts/download-lua-ls.js` + // before running electron-builder. The directory is only included when it exists + // (i.e. the download script has been run); builds without it still work but LuaLS + // features will be unavailable at runtime. + from: "resources/lua-language-server", + to: "lua-language-server", + filter: ["**/*"], + }, ], files: ["**/*"], win: { diff --git a/package.json b/package.json index 6d92482aa..cf54e9f0b 100644 --- a/package.json +++ b/package.json @@ -78,13 +78,14 @@ "yauzl": "^3.2.0" }, "scripts": { + "download-lua-ls": "node build-scripts/download-lua-ls.js", "e:builder": "cross-env VITE_BUILD_TARGET=electron NOTARIZE=true electron-builder --config electron-builder-config.js --publish onTagOrDraft", "e:builder:nightly": "cross-env VITE_BUILD_ENV=nightly VITE_BUILD_TARGET=electron NOTARIZE=true DEBUG=electron-notarize* electron-builder --config electron-builder-config.js --publish never", "e:builder:local": "cross-env VITE_BUILD_ENV=nightly VITE_BUILD_TARGET=electron DEBUG=electron-builder DEBUG=electron-notarize* electron-builder --config electron-builder-config.js --publish never", - "export": "cross-env VITE_BUILD_ENV=production run-s s:build e:builder", - "export:nightly": "run-s s:build e:builder:nightly", - "export:alpha": "cross-env VITE_BUILD_ENV=alpha run-s s:build e:builder", - "export:local": "run-s clean-up s:build e:builder:local", + "export": "cross-env VITE_BUILD_ENV=production run-s download-lua-ls s:build e:builder", + "export:nightly": "run-s download-lua-ls s:build e:builder:nightly", + "export:alpha": "cross-env VITE_BUILD_ENV=alpha run-s download-lua-ls s:build e:builder", + "export:local": "run-s clean-up download-lua-ls s:build e:builder:local", "clean-up": "rm -rf dist && rm -rf dist-web && rm -rf build", "rebuild": "./node_modules/.bin/electron-rebuild.cmd", "s:build": "electron-vite build", diff --git a/src/electron/main.ts b/src/electron/main.ts index 652d9d1df..c55fa67dd 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -86,6 +86,7 @@ import { } from "./src/profiles"; import { fetchReleaseNotes, fetchUrlJSON } from "./src/fetch"; import { getLatestVideo } from "./src/youtube"; +import { getLuaLanguageServer } from "./src/lua-language-server"; log.info("App starting..."); log.info("BUILD ENVS:", import.meta.env); @@ -94,6 +95,9 @@ log.info("BUILD ENVS:", import.meta.env); // be closed automatically when the JavaScript object is garbage collected. let mainWindow; +// LuaLS WebSocket proxy port (0 = not started / binary not found) +let luaLsPort = 0; + // Bootloader detection service state let bootloaderDetectionTimeout: NodeJS.Timeout | null = null; let bootloaderDetectionRunning = false; @@ -356,6 +360,28 @@ if (!gotTheLock) { create_tray(); } createWindow(); + + // Start the LuaLS proxy server. The port is exposed to the renderer via + // the "getLuaLsPort" IPC channel (see preload.ts). + const annotationsDir = path.resolve( + __dirname, + "..", + "..", + "renderer", + "assets", + "lua", + "annotations", + ); + getLuaLanguageServer() + .start({ annotationsDir }) + .then((port) => { + luaLsPort = port; + log.info(`[LuaLS] Proxy started on port ${port}`); + }) + .catch((err) => { + log.warn("[LuaLS] Failed to start proxy:", err); + }); + protocol.handle("package", (req) => { let requestPath = req.url.substring("package://".length); @@ -1001,6 +1027,10 @@ ipcMain.on("get-app-path", (event) => { event.returnValue = app.getAppPath(); }); +ipcMain.on("getLuaLsPort", (event) => { + event.returnValue = luaLsPort; +}); + ipcMain.on("analytics_uuid", (event) => { event.returnValue = store.get("userId"); }); @@ -1085,4 +1115,5 @@ app.on("activate", () => { app.on("before-quit", (evt) => { log.info("before-quit evt", evt); app.quitting = true; + getLuaLanguageServer().stop(); }); diff --git a/src/electron/preload.ts b/src/electron/preload.ts index ecec79a7b..5bd11181f 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -113,6 +113,9 @@ contextBridge.exposeInMainWorld("electron", { appLoaded: () => appLoadedPromiseResolve(undefined), showQuitDialog: (callback) => ipcRenderer.on("showQuitDialog", callback), quitDialogResult: (result) => ipcRenderer.send("quitDialogResult", result), + luaLanguageServer: { + getPort: () => ipcRenderer.sendSync("getLuaLsPort") as number, + }, }); let appLoadedPromiseResolve: (any) => any; diff --git a/src/electron/src/lua-language-server.ts b/src/electron/src/lua-language-server.ts new file mode 100644 index 000000000..6ff879409 --- /dev/null +++ b/src/electron/src/lua-language-server.ts @@ -0,0 +1,325 @@ +/** + * lua-language-server.ts + * + * Manages the LuaLS (lua-language-server) child process and exposes it to the + * renderer via a local WebSocket server. + * + * Architecture + * ────────────────────────────────────────────────────────────────────────── + * Renderer (Monaco) + * │ native WebSocket (ws://127.0.0.1:) + * ▼ + * WebSocket server (this file, running in Electron main) + * │ JSON-RPC over stdio (LSP wire format) + * ▼ + * lua-language-server binary + * + * Each WebSocket client receives its own dedicated connection to a fresh + * LuaLS process, which keeps language-server state isolated per editor tab. + * In practice the editor opens a single connection at start-up. + * + * LSP Message Framing + * ────────────────────────────────────────────────────────────────────────── + * LuaLS speaks the standard LSP wire format over stdin/stdout: + * + * Content-Length: \r\n + * \r\n + * + * + * We strip/add these headers when bridging to/from the WebSocket where each + * message is simply the raw JSON string. + */ + +import { spawn, ChildProcess } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; +import { app } from "electron"; +import log from "electron-log"; + +// ws is already a production dependency of grid-editor. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const WebSocket = require("ws"); + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface LuaLsOptions { + /** Absolute path to the directory containing the Grid Lua annotation files. */ + annotationsDir: string; +} + +// ── Binary location ─────────────────────────────────────────────────────────── + +/** + * Returns the absolute path to the lua-language-server binary. + * + * Resolution order: + * 1. Bundled binary inside `resources/lua-language-server/bin/` (production). + * 2. Development: `/resources/lua-language-server/bin/`. + * 3. PATH (fallback for developer environments where LuaLS is installed globally). + */ +export function findLuaLsBinary(): string | null { + const binaryName = + process.platform === "win32" + ? "lua-language-server.exe" + : "lua-language-server"; + + // 1. Production: extraResources places the folder at /resources/ + const resourcesPath = process.resourcesPath + ? path.join(process.resourcesPath, "lua-language-server", "bin", binaryName) + : null; + + if (resourcesPath && fs.existsSync(resourcesPath)) { + return resourcesPath; + } + + // 2. Development: relative to the project root + const devPath = path.resolve( + __dirname, + "..", + "..", + "..", + "resources", + "lua-language-server", + "bin", + binaryName, + ); + if (fs.existsSync(devPath)) { + return devPath; + } + + // 3. System PATH (developer convenience) + try { + const which = process.platform === "win32" ? "where" : "which"; + const result = require("child_process") + .execSync(`${which} lua-language-server`, { stdio: "pipe" }) + .toString() + .trim() + .split("\n")[0]; + if (result && fs.existsSync(result)) { + return result; + } + } catch { + // not on PATH + } + + return null; +} + +// ── LSP message framing ─────────────────────────────────────────────────────── + +const HEADER_RE = /Content-Length:\s*(\d+)\r?\n\r?\n/; + +/** + * Converts raw LSP stdio output (possibly multiple messages) into an array + * of JSON payload strings, one per message. + */ +class LspStdioParser { + private buffer = ""; + + push(chunk: string): string[] { + this.buffer += chunk; + const messages: string[] = []; + + while (true) { + const headerMatch = HEADER_RE.exec(this.buffer); + if (!headerMatch) break; + + const headerEnd = headerMatch.index + headerMatch[0].length; + const length = parseInt(headerMatch[1], 10); + + if (this.buffer.length < headerEnd + length) break; // need more data + + const payload = this.buffer.slice(headerEnd, headerEnd + length); + this.buffer = this.buffer.slice(headerEnd + length); + messages.push(payload); + } + + return messages; + } +} + +/** + * Wraps a JSON string in the LSP Content-Length header. + */ +function frameLspMessage(json: string): Buffer { + const body = Buffer.from(json, "utf8"); + const header = `Content-Length: ${body.length}\r\n\r\n`; + return Buffer.concat([Buffer.from(header, "ascii"), body]); +} + +// ── LuaLanguageServer ───────────────────────────────────────────────────────── + +export class LuaLanguageServer { + private wss: InstanceType | null = null; + private port = 0; + + /** Start the WebSocket proxy server. Returns the port number. */ + async start(options: LuaLsOptions): Promise { + const binaryPath = findLuaLsBinary(); + if (!binaryPath) { + log.warn( + "[LuaLS] lua-language-server binary not found. " + + "Run `node build-scripts/download-lua-ls.js` to install it. " + + "LuaLS features will be unavailable.", + ); + return 0; + } + + log.info(`[LuaLS] Using binary: ${binaryPath}`); + log.info(`[LuaLS] Annotations dir: ${options.annotationsDir}`); + + return new Promise((resolve, reject) => { + const server = new WebSocket.Server({ host: "127.0.0.1", port: 0 }, () => { + this.port = (server.address() as { port: number }).port; + this.wss = server; + log.info(`[LuaLS] WebSocket proxy listening on port ${this.port}`); + resolve(this.port); + }); + + server.on("error", reject); + + server.on( + "connection", + (ws: InstanceType) => { + this.handleConnection(ws, binaryPath, options.annotationsDir); + }, + ); + }); + } + + /** Stop the WebSocket server. */ + stop() { + if (this.wss) { + this.wss.close(); + this.wss = null; + log.info("[LuaLS] WebSocket proxy stopped."); + } + } + + // ── Per-connection handler ────────────────────────────────────────────────── + + private handleConnection( + ws: InstanceType, + binaryPath: string, + annotationsDir: string, + ) { + log.info("[LuaLS] Renderer connected, spawning lua-language-server…"); + + // Create a temporary workspace directory for this session. + // LuaLS writes its log and cache files here to avoid polluting the user's + // project directory. + const tmpWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), "grid-lua-ls-"), + ); + + const luaProcess = this.spawnLuaLs( + binaryPath, + annotationsDir, + tmpWorkspace, + ); + + if (!luaProcess) { + ws.close(); + return; + } + + const parser = new LspStdioParser(); + + // LuaLS stdout → WebSocket + luaProcess.stdout!.setEncoding("utf8"); + luaProcess.stdout!.on("data", (data: string) => { + const messages = parser.push(data); + for (const msg of messages) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } + } + }); + + luaProcess.stderr!.setEncoding("utf8"); + luaProcess.stderr!.on("data", (data: string) => { + // LuaLS writes diagnostic/progress info to stderr — log at debug level + log.debug("[LuaLS stderr]", data.trim()); + }); + + luaProcess.on("exit", (code: number | null) => { + log.info(`[LuaLS] Process exited with code ${code}`); + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + // Clean up temp workspace + fs.rm(tmpWorkspace, { recursive: true, force: true }, () => {}); + }); + + // WebSocket → LuaLS stdin + ws.on("message", (data: Buffer | string) => { + const json = typeof data === "string" ? data : data.toString("utf8"); + try { + luaProcess.stdin!.write(frameLspMessage(json)); + } catch (err) { + log.warn("[LuaLS] Failed to write to stdin:", err); + } + }); + + ws.on("close", () => { + log.info("[LuaLS] Renderer disconnected, killing lua-language-server."); + luaProcess.kill(); + }); + + ws.on("error", (err: Error) => { + log.warn("[LuaLS] WebSocket error:", err); + luaProcess.kill(); + }); + } + + // ── Process spawn ─────────────────────────────────────────────────────────── + + private spawnLuaLs( + binaryPath: string, + annotationsDir: string, + logDir: string, + ): ChildProcess | null { + try { + const args = [ + "--stdio", + `--logpath=${logDir}`, + ]; + + const env: NodeJS.ProcessEnv = { + ...process.env, + // Suppress the LuaLS telemetry prompt + LUA_LS_TELEMETRY: "0", + }; + + const child = spawn(binaryPath, args, { + stdio: ["pipe", "pipe", "pipe"], + env, + // Use the annotations directory as the workspace root so LuaLS + // automatically picks up the annotation files. + cwd: annotationsDir, + }); + + child.on("error", (err) => { + log.error("[LuaLS] Failed to start process:", err); + }); + + return child; + } catch (err) { + log.error("[LuaLS] spawn failed:", err); + return null; + } + } +} + +// ── Singleton ───────────────────────────────────────────────────────────────── + +let _instance: LuaLanguageServer | null = null; + +/** Get or create the singleton LuaLanguageServer manager. */ +export function getLuaLanguageServer(): LuaLanguageServer { + if (!_instance) { + _instance = new LuaLanguageServer(); + } + return _instance; +} diff --git a/src/renderer/assets/lua/annotations/grid.lua b/src/renderer/assets/lua/annotations/grid.lua new file mode 100644 index 000000000..51d3a49d7 --- /dev/null +++ b/src/renderer/assets/lua/annotations/grid.lua @@ -0,0 +1,742 @@ +---@meta grid + +-- Grid Firmware Lua API Annotations +-- These annotations provide type information for the Lua Language Server (LuaLS) +-- when editing Grid firmware scripts in the Grid Editor. + +--------------------------------------------------------------------------- +-- Grid Element (accessed via `self` inside event handlers, or `element[n]`) +--------------------------------------------------------------------------- + +---@class GridElement +local GridElement = {} + +-- ── Button functions ────────────────────────────────────────────────── + +---Get or set the button value. +---@param value? integer 0 = released, 1 = pressed +---@return integer +function GridElement:button_value(value) end + +---Get or set the button state. +---@param state? integer +---@return integer +function GridElement:button_state(state) end + +---Get or set the minimum button value. +---@param min? integer +---@return integer +function GridElement:button_min(min) end + +---Get or set the maximum button value. +---@param max? integer +---@return integer +function GridElement:button_max(max) end + +---Get or set the button mode. +---@param mode? integer 0 = momentary, 1 = toggle +---@return integer +function GridElement:button_mode(mode) end + +---Get or set the button step size. +---@param step? integer +---@return integer +function GridElement:button_step(step) end + +---Get the elapsed time since the last button event (ms). +---@return integer +function GridElement:button_elapsed_time() end + +-- ── Encoder functions ───────────────────────────────────────────────── + +---Get or set the encoder value. +---@param value? integer +---@return integer +function GridElement:encoder_value(value) end + +---Get or set the encoder state. +---@param state? integer +---@return integer +function GridElement:encoder_state(state) end + +---Get or set the encoder minimum. +---@param min? integer +---@return integer +function GridElement:encoder_min(min) end + +---Get or set the encoder maximum. +---@param max? integer +---@return integer +function GridElement:encoder_max(max) end + +---Get or set the encoder mode. +---@param mode? integer +---@return integer +function GridElement:encoder_mode(mode) end + +---Get or set the encoder number. +---@param num? integer +---@return integer +function GridElement:encoder_number(num) end + +---Get or set the encoder sensitivity. +---@param sensitivity? integer +---@return integer +function GridElement:encoder_sensitivity(sensitivity) end + +---Get the elapsed time since the last encoder event (ms). +---@return integer +function GridElement:encoder_elapsed_time() end + +---Get the encoder velocity. +---@return integer +function GridElement:encoder_velocity() end + +-- ── Endless Encoder functions ────────────────────────────────────────── + +---Get or set the endless encoder value. +---@param value? integer +---@return integer +function GridElement:endless_value(value) end + +---Get or set the endless encoder state. +---@param state? integer +---@return integer +function GridElement:endless_state(state) end + +---Get or set the endless encoder minimum. +---@param min? integer +---@return integer +function GridElement:endless_min(min) end + +---Get or set the endless encoder maximum. +---@param max? integer +---@return integer +function GridElement:endless_max(max) end + +---Get or set the endless encoder mode. +---@param mode? integer +---@return integer +function GridElement:endless_mode(mode) end + +---Get or set the endless encoder sensitivity. +---@param sensitivity? integer +---@return integer +function GridElement:endless_sensitivity(sensitivity) end + +---Get the endless encoder direction. +---@return integer 1 = clockwise, -1 = counter-clockwise +function GridElement:endless_direction() end + +---Get the elapsed time since the last endless encoder event (ms). +---@return integer +function GridElement:endless_elapsed_time() end + +---Get the endless encoder velocity. +---@return integer +function GridElement:endless_velocity() end + +---Get or set the LED offset for the endless encoder. +---@param offset? integer +---@return integer +function GridElement:led_offset(offset) end + +-- ── Potentiometer functions ──────────────────────────────────────────── + +---Get or set the potentiometer value. +---@param value? integer 0–127 +---@return integer +function GridElement:potmeter_value(value) end + +---Get or set the potentiometer state. +---@param state? integer +---@return integer +function GridElement:potmeter_state(state) end + +---Get or set the potentiometer minimum. +---@param min? integer +---@return integer +function GridElement:potmeter_min(min) end + +---Get or set the potentiometer maximum. +---@param max? integer +---@return integer +function GridElement:potmeter_max(max) end + +---Get or set the potentiometer resolution mode. +---@param mode? integer +---@return integer +function GridElement:potmeter_resolution(mode) end + +---Get the elapsed time since the last potentiometer event (ms). +---@return integer +function GridElement:potmeter_elapsed_time() end + +-- ── LCD / Display functions ─────────────────────────────────────────── + +---Draw a single pixel on the LCD. +---@param x integer X position +---@param y integer Y position +---@param color integer Color value +function GridElement:draw_pixel(x, y, color) end + +---Draw a line on the LCD. +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param color integer +function GridElement:draw_line(x1, y1, x2, y2, color) end + +---Draw a rectangle outline on the LCD. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function GridElement:draw_rectangle(x, y, w, h, color) end + +---Draw a filled rectangle on the LCD. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function GridElement:draw_rectangle_filled(x, y, w, h, color) end + +---Draw a rounded rectangle outline on the LCD. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param r integer Corner radius +---@param color integer +function GridElement:draw_rectangle_rounded(x, y, w, h, r, color) end + +---Draw a filled rounded rectangle on the LCD. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param r integer +---@param color integer +function GridElement:draw_rectangle_rounded_filled(x, y, w, h, r, color) end + +---Draw a polygon outline. +---@param points table Array of {x, y} coordinate pairs +---@param color integer +function GridElement:draw_polygon(points, color) end + +---Draw a filled polygon. +---@param points table +---@param color integer +function GridElement:draw_polygon_filled(points, color) end + +---Draw a filled area. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function GridElement:draw_area_filled(x, y, w, h, color) end + +---Render text on the LCD. +---@param x integer +---@param y integer +---@param text string +---@param color integer +function GridElement:draw_text(x, y, text, color) end + +---Render text quickly (faster, lower quality) on the LCD. +---@param x integer +---@param y integer +---@param text string +---@param color integer +function GridElement:draw_text_fast(x, y, text, color) end + +---Swap the display buffer (apply all pending draw calls). +function GridElement:draw_swap() end + +---Run the built-in LCD demo. +function GridElement:draw_demo() end + +---Get the time taken for the last render cycle (ms). +---@return integer +function GridElement:get_render_time() end + +---Get the LCD screen width in pixels. +---@return integer +function GridElement:screen_width() end + +---Get the LCD screen height in pixels. +---@return integer +function GridElement:screen_height() end + +---Get or set the LCD screen index. +---@param index? integer +---@return integer +function GridElement:screen_index(index) end + +-- ── Common element getters ───────────────────────────────────────────── + +---Get the element index (0-based position on the module). +---@return integer +function GridElement:element_index() end + +---Get the LED index for this element. +---@return integer +function GridElement:led_index() end + +---Get or set the LED color for this element. +---@param layer? integer LED layer (1 or 2) +---@param color? integer Packed RGB color +---@return integer +function GridElement:led_color(layer, color) end + +--------------------------------------------------------------------------- +-- Global Grid functions (available in all scripts as top-level functions) +--------------------------------------------------------------------------- + +---Send a MIDI message. +---@param channel integer MIDI channel (0–15) +---@param command integer MIDI command byte (128–255) +---@param param1 integer First parameter (0–127) +---@param param2 integer Second parameter (0–127) +function midi_send(channel, command, param1, param2) end + +---Send a raw MIDI SysEx message. +---@param data table Array of bytes +function midi_sysex_send(data) end + +---Get or set the automatic MIDI channel. +---@param channel? integer +---@return integer +function midi_auto_ch(channel) end + +---Get or set the automatic MIDI command. +---@param command? integer +---@return integer +function midi_auto_cmd(command) end + +---Get or set automatic MIDI parameter 1. +---@param param? integer +---@return integer +function midi_auto_p1(param) end + +---Get or set automatic MIDI parameter 2. +---@param param? integer +---@return integer +function midi_auto_p2(param) end + +---Enable or disable incoming MIDI Rx. +---@param enabled? integer 1 = enabled, 0 = disabled +---@return integer +function midirx_enabled(enabled) end + +---Synchronise MIDI Rx state. +function midirx_sync() end + +---Send a keyboard keystroke. +---@param ... integer HID keycodes +function keyboard_send(...) end + +---Send a mouse move event. +---@param x integer Horizontal delta +---@param y integer Vertical delta +function mouse_move_send(x, y) end + +---Send a mouse button event. +---@param button integer Button index +---@param state integer 1 = pressed, 0 = released +function mouse_button_send(button, state) end + +---Send a gamepad axis move event. +---@param axis integer +---@param value integer +function gamepad_move_send(axis, value) end + +---Send a gamepad button event. +---@param button integer +---@param state integer +function gamepad_button_send(button, state) end + +---Load a page by index. +---@param page integer Page number (0-based) +function page_load(page) end + +---Get or set the current page index. +---@param page? integer +---@return integer +function page_current(page) end + +---Switch to the next page. +function page_next() end + +---Switch to the previous page. +function page_previous() end + +---Trigger an element event programmatically. +---@param element_index integer +---@param event_type integer +function event_trigger(element_index, event_type) end + +---Constrain a value between min and max. +---@param value integer +---@param min integer +---@param max integer +---@return integer +function limit(value, min, max) end + +---Map and clamp a value to a target range, clamping at the boundaries. +---@param value integer Input value +---@param in_min integer Input range minimum +---@param in_max integer Input range maximum +---@param out_min integer Output range minimum +---@param out_max integer Output range maximum +---@return integer +function map_saturate(value, in_min, in_max, out_min, out_max) end + +---Get the sign of a value. +---@param value integer +---@return integer -1, 0, or 1 +function sign(value) end + +---Get the count of elements on the current module. +---@return integer +function element_count() end + +---Get or set the element name. +---@param element_index integer +---@param name? string +---@return string +function element_name(element_index, name) end + +---Retrieve the element name (from firmware). +---@param element_index integer +function element_name_get(element_index) end + +---Push the element name to firmware. +---@param element_index integer +function element_name_send(element_index) end + +---Set the element name. +---@param element_index integer +---@param name string +function element_name_set(element_index, name) end + +---Get the X position of the module on the grid. +---@return integer +function module_position_x() end + +---Get the Y position of the module on the grid. +---@return integer +function module_position_y() end + +---Get the rotation of the module (0, 90, 180, or 270 degrees). +---@return integer +function module_rotation() end + +---Get the hardware configuration identifier. +---@return integer +function hardware_configuration() end + +---Get the firmware major version. +---@return integer +function version_major() end + +---Get the firmware minor version. +---@return integer +function version_minor() end + +---Get the firmware patch version. +---@return integer +function version_patch() end + +---Get a random 8-bit integer (0–255). +---@return integer +function random8() end + +---Set the global LED color minimum intensity. +---@param value? integer +---@return integer +function led_color_min(value) end + +---Set the global LED color mid intensity. +---@param value? integer +---@return integer +function led_color_mid(value) end + +---Set the global LED color maximum intensity. +---@param value? integer +---@return integer +function led_color_max(value) end + +---Get or set the default LED red component. +---@param value? integer +---@return integer +function led_default_red(value) end + +---Get or set the default LED green component. +---@param value? integer +---@return integer +function led_default_green(value) end + +---Get or set the default LED blue component. +---@param value? integer +---@return integer +function led_default_blue(value) end + +---Get the address of the LED at the given position. +---@param index integer +---@return integer +function led_address_get(index) end + +---Get or set the LED animation value/phase. +---@param layer? integer +---@param value? integer +---@return integer +function led_value(layer, value) end + +---Get or set the LED animation rate/frequency. +---@param layer? integer +---@param rate? integer +---@return integer +function led_animation_rate(layer, rate) end + +---Get or set the LED animation phase rate type. +---@param layer? integer +---@param type? integer +---@return integer +function led_animation_phase_rate_type(layer, type) end + +---Get or set the LED animation shape/type. +---@param layer? integer +---@param shape? integer +---@return integer +function led_animation_type(layer, shape) end + +---Get or set the LED timeout (ms). +---@param timeout? integer +---@return integer +function led_timeout(timeout) end + +---Get or set the LED color for a specific layer and LED index. +---@param led_index integer +---@param layer integer +---@param color? integer Packed RGB value +---@return integer +function led_color(led_index, layer, color) end + +---Calculate LED layer from color_auto_layer override hook. +---@param self GridElement +---@return integer +function color_auto_layer(self) end + +---Calculate LED value from color_auto_value override hook. +---@param self GridElement +---@param segment_index integer +---@return integer +function color_auto_value(self, segment_index) end + +---Calculate three-point LED color response curve. +---@param color_array table +---@return table, table, table +function color_curve(color_array) end + +---Decode a packed value. +---@param value integer +---@return integer, integer, integer, integer +function decode(value) end + +---Look up a value in a table. +---@param index integer +---@param ... integer +---@return integer +function lookup(index, ...) end + +---Calculate the LED segment index. +---@param value integer +---@param segments integer +---@return integer +function segment_calculate(value, segments) end + +---Get a string by index. +---@param index integer +---@return string +function string_get(index) end + +---Start a timer. +---@param timer_id integer +---@param interval integer Interval in ms +function timer_start(timer_id, interval) end + +---Stop a timer. +---@param timer_id integer +function timer_stop(timer_id) end + +---Get or set the timer source. +---@param timer_id integer +---@param source? integer +---@return integer +function timer_source(timer_id, source) end + +---Send an immediate Lua script to the grid. +---@param script string +function immediate_send(script) end + +---Send a package payload. +---@param payload string +function package_send(payload) end + +---Send a WebSocket message. +---@param message string +function websocket_send(message) end + +---Reset potentiometer calibration. +function calibration_reset() end + +---Get the current potentiometer calibration. +---@param element_index integer +---@return table +function potmeter_calibration_get(element_index) end + +---Get the current range calibration. +---@param element_index integer +---@return table +function range_calibration_get(element_index) end + +---Set range calibration values. +---@param element_index integer +---@param min integer +---@param max integer +function range_calibration_set(element_index, min, max) end + +---Set the center calibration point for a potentiometer. +---@param element_index integer +---@param center integer +function potmeter_center_set(element_index, center) end + +---Set the detent calibration point for a potentiometer. +---@param element_index integer +---@param detent integer +function potmeter_detent_set(element_index, detent) end + +---Set the LCD backlight level. +---@param level integer 0–255 +function lcd_set_backlight(level) end + +---Read a file from the device filesystem. +---@param path string +---@return string +function readfile(path) end + +---List the contents of a directory on the device filesystem. +---@param path string +---@return table +function readdir(path) end + +--------------------------------------------------------------------------- +-- Global GUI draw functions (available on modules with a built-in display) +--------------------------------------------------------------------------- + +---Draw a pixel on the global display. +---@param x integer +---@param y integer +---@param color integer +function gui_draw_pixel(x, y, color) end + +---Draw a line on the global display. +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param color integer +function gui_draw_line(x1, y1, x2, y2, color) end + +---Draw a rectangle outline on the global display. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function gui_draw_rectangle(x, y, w, h, color) end + +---Draw a filled rectangle on the global display. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function gui_draw_rectangle_filled(x, y, w, h, color) end + +---Draw a rounded rectangle on the global display. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param r integer +---@param color integer +function gui_draw_rectangle_rounded(x, y, w, h, r, color) end + +---Draw a filled rounded rectangle on the global display. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param r integer +---@param color integer +function gui_draw_rectangle_rounded_filled(x, y, w, h, r, color) end + +---Draw a polygon on the global display. +---@param points table +---@param color integer +function gui_draw_polygon(points, color) end + +---Draw a filled polygon on the global display. +---@param points table +---@param color integer +function gui_draw_polygon_filled(points, color) end + +---Draw a filled area on the global display. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function gui_draw_area_filled(x, y, w, h, color) end + +---Render text on the global display. +---@param x integer +---@param y integer +---@param text string +---@param color integer +function gui_draw_text(x, y, text, color) end + +---Render text quickly on the global display. +---@param x integer +---@param y integer +---@param text string +---@param color integer +function gui_draw_fasttext(x, y, text, color) end + +---Swap the display buffer on the global display. +function gui_draw_swap() end + +---Run the built-in demo on the global display. +function gui_draw_demo() end + +---Get the global display render time (ms). +---@return integer +function gui_get_render_time() end + +--------------------------------------------------------------------------- +-- Pre-defined variables available inside event handlers +--------------------------------------------------------------------------- + +---The element that fired the current event. +---@type GridElement +self = {} + +---An array of all elements on the current module (0-indexed). +---@type GridElement[] +element = {} diff --git a/src/renderer/lib/lua-ls-client.ts b/src/renderer/lib/lua-ls-client.ts new file mode 100644 index 000000000..5699e266a --- /dev/null +++ b/src/renderer/lib/lua-ls-client.ts @@ -0,0 +1,630 @@ +/** + * lua-ls-client.ts + * + * A lightweight LSP (Language Server Protocol) client that connects to the + * lua-language-server proxy exposed by the Electron main process over a local + * WebSocket and wires the server's capabilities into Monaco Editor. + * + * What this module does + * ────────────────────────────────────────────────────────────────────────── + * • Opens a WebSocket to ws://127.0.0.1: (the LuaLS proxy in main). + * • Performs the LSP initialize / initialized handshake. + * • Keeps Monaco's open documents in sync with LuaLS via + * textDocument/didOpen, textDocument/didChange, textDocument/didClose. + * • Registers Monaco providers for: + * – Completions (textDocument/completion) + * – Hover (textDocument/hover) + * – Diagnostics (textDocument/publishDiagnostics notification) + * • Handles graceful reconnection when the WebSocket drops. + * + * Design decisions + * ────────────────────────────────────────────────────────────────────────── + * • No heavy third-party LSP client library. The LSP protocol over WebSocket + * is simple enough to implement here: each WS message is a JSON-RPC object. + * • Diagnostics are pushed by the server; completions and hover are pulled on + * demand through the Monaco provider API. + * • The language ID sent to LuaLS is always "lua" so LuaLS treats the + * documents as standard Lua (it doesn't know about "intech_lua"). + */ + +import { + editor as monacoEditor, + languages as monacoLanguages, + MarkerSeverity, +} from "monaco-editor"; + +// ── JSON-RPC / LSP types (minimal) ─────────────────────────────────────────── + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params?: unknown; +} + +interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +// ── LSP position helpers ────────────────────────────────────────────────────── + +function toLspPosition(position: monacoEditor.IPosition) { + return { line: position.lineNumber - 1, character: position.column - 1 }; +} + +function fromLspRange(range: { + start: { line: number; character: number }; + end: { line: number; character: number }; +}): monacoEditor.IRange { + return { + startLineNumber: range.start.line + 1, + startColumn: range.start.character + 1, + endLineNumber: range.end.line + 1, + endColumn: range.end.character + 1, + }; +} + +// ── Completion item kind mapping ────────────────────────────────────────────── + +const LSP_COMPLETION_KIND_MAP: Record< + number, + monacoLanguages.CompletionItemKind +> = { + 1: monacoLanguages.CompletionItemKind.Text, + 2: monacoLanguages.CompletionItemKind.Method, + 3: monacoLanguages.CompletionItemKind.Function, + 4: monacoLanguages.CompletionItemKind.Constructor, + 5: monacoLanguages.CompletionItemKind.Field, + 6: monacoLanguages.CompletionItemKind.Variable, + 7: monacoLanguages.CompletionItemKind.Class, + 8: monacoLanguages.CompletionItemKind.Interface, + 9: monacoLanguages.CompletionItemKind.Module, + 10: monacoLanguages.CompletionItemKind.Property, + 11: monacoLanguages.CompletionItemKind.Unit, + 12: monacoLanguages.CompletionItemKind.Value, + 13: monacoLanguages.CompletionItemKind.Enum, + 14: monacoLanguages.CompletionItemKind.Keyword, + 15: monacoLanguages.CompletionItemKind.Snippet, + 16: monacoLanguages.CompletionItemKind.Color, + 17: monacoLanguages.CompletionItemKind.File, + 18: monacoLanguages.CompletionItemKind.Reference, + 19: monacoLanguages.CompletionItemKind.Folder, + 20: monacoLanguages.CompletionItemKind.EnumMember, + 21: monacoLanguages.CompletionItemKind.Constant, + 22: monacoLanguages.CompletionItemKind.Struct, + 23: monacoLanguages.CompletionItemKind.Event, + 24: monacoLanguages.CompletionItemKind.Operator, + 25: monacoLanguages.CompletionItemKind.TypeParameter, +}; + +// ── Diagnostic severity mapping ─────────────────────────────────────────────── + +const LSP_SEVERITY_MAP: Record = { + 1: MarkerSeverity.Error, + 2: MarkerSeverity.Warning, + 3: MarkerSeverity.Info, + 4: MarkerSeverity.Hint, +}; + +// ── LuaLsClient ─────────────────────────────────────────────────────────────── + +export class LuaLsClient { + private ws: WebSocket | null = null; + private nextId = 1; + private pendingRequests = new Map< + number, + { resolve: (v: unknown) => void; reject: (e: Error) => void } + >(); + private initialized = false; + private documentVersions = new Map(); + private disposables: monacoLanguages.IDisposable[] = []; + private reconnectTimeout: ReturnType | null = null; + private stopped = false; + + constructor( + private readonly port: number, + /** Monaco language IDs to attach the providers to. */ + private readonly languageIds: string[] = ["intech_lua", "lua"], + ) {} + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + /** Connect to the LuaLS proxy and register Monaco providers. */ + async start(): Promise { + if (this.port === 0) { + console.warn( + "[LuaLsClient] LuaLS port is 0 — server not running. " + + "LSP features will be unavailable.", + ); + return; + } + + await this.connect(); + this.registerMonacoProviders(); + this.attachModelListeners(); + } + + /** Disconnect and clean up all Monaco provider registrations. */ + stop(): void { + this.stopped = true; + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout); + } + for (const d of this.disposables) d.dispose(); + this.disposables = []; + this.ws?.close(); + this.ws = null; + } + + // ── WebSocket connection ──────────────────────────────────────────────────── + + private connect(): Promise { + return new Promise((resolve, reject) => { + const url = `ws://127.0.0.1:${this.port}`; + const ws = new WebSocket(url); + this.ws = ws; + + ws.onopen = async () => { + try { + await this.initialize(); + resolve(); + } catch (err) { + reject(err); + } + }; + + ws.onmessage = (event: MessageEvent) => { + try { + const msg: JsonRpcMessage = JSON.parse(event.data as string); + this.handleMessage(msg); + } catch (err) { + console.warn("[LuaLsClient] Failed to parse message:", err); + } + }; + + ws.onclose = () => { + this.initialized = false; + if (!this.stopped) { + console.info("[LuaLsClient] WebSocket closed, scheduling reconnect…"); + this.reconnectTimeout = setTimeout(() => this.reconnect(), 3000); + } + }; + + ws.onerror = (err: Event) => { + console.warn("[LuaLsClient] WebSocket error:", err); + if (!this.initialized) reject(new Error("WebSocket connection failed")); + }; + }); + } + + private async reconnect(): Promise { + if (this.stopped) return; + try { + await this.connect(); + // Re-open all currently loaded documents + for (const model of monacoEditor.getModels()) { + if (this.languageIds.includes(model.getLanguageId())) { + this.openDocument(model); + } + } + } catch (err) { + console.warn("[LuaLsClient] Reconnect failed:", err); + this.reconnectTimeout = setTimeout(() => this.reconnect(), 5000); + } + } + + // ── LSP handshake ─────────────────────────────────────────────────────────── + + private async initialize(): Promise { + const result = await this.sendRequest("initialize", { + processId: null, + clientInfo: { name: "grid-editor", version: "1.0" }, + rootUri: null, + capabilities: { + textDocument: { + synchronization: { + didSave: false, + dynamicRegistration: false, + }, + completion: { + completionItem: { + snippetSupport: false, + documentationFormat: ["markdown", "plaintext"], + }, + }, + hover: { + contentFormat: ["markdown", "plaintext"], + }, + publishDiagnostics: { + relatedInformation: false, + }, + }, + workspace: { + workspaceFolders: false, + }, + }, + initializationOptions: { + // Tell LuaLS where to find the Grid annotation files + addonManager: { enable: false }, + telemetry: { enable: false }, + diagnostics: { enable: true }, + completion: { enable: true, callSnippet: "Disable" }, + hint: { enable: false }, + }, + }); + + if (!result) throw new Error("initialize returned null"); + + this.sendNotification("initialized", {}); + this.initialized = true; + + // Open all currently existing models + for (const model of monacoEditor.getModels()) { + if (this.languageIds.includes(model.getLanguageId())) { + this.openDocument(model); + } + } + } + + // ── Message dispatch ──────────────────────────────────────────────────────── + + private handleMessage(msg: JsonRpcMessage): void { + if ("id" in msg && "result" in msg) { + // Response to a request we sent + const pending = this.pendingRequests.get(msg.id as number); + if (pending) { + this.pendingRequests.delete(msg.id as number); + if (msg.error) { + pending.reject(new Error(msg.error.message)); + } else { + pending.resolve(msg.result); + } + } + } else if ("method" in msg && !("id" in msg)) { + // Server-initiated notification + this.handleNotification( + msg as JsonRpcNotification, + ); + } + // Ignore server-initiated requests for now (LuaLS rarely sends those) + } + + private handleNotification(msg: JsonRpcNotification): void { + if (msg.method === "textDocument/publishDiagnostics") { + this.applyDiagnostics(msg.params as { + uri: string; + diagnostics: Array<{ + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + severity?: number; + message: string; + }>; + }); + } + } + + // ── Diagnostics ───────────────────────────────────────────────────────────── + + private applyDiagnostics(params: { + uri: string; + diagnostics: Array<{ + range: { start: { line: number; character: number }; end: { line: number; character: number } }; + severity?: number; + message: string; + }>; + }): void { + const model = monacoEditor.getModel( + { scheme: "file", path: params.uri.replace(/^file:\/\//, "") } as any, + ) ?? this.findModelByUri(params.uri); + + if (!model) return; + + const markers = params.diagnostics.map((d) => ({ + ...fromLspRange(d.range), + severity: LSP_SEVERITY_MAP[d.severity ?? 1] ?? MarkerSeverity.Error, + message: d.message, + source: "lua-language-server", + })); + + monacoEditor.setModelMarkers(model, "lua-language-server", markers); + } + + /** Fallback: find model by comparing URIs as strings. */ + private findModelByUri(uri: string): monacoEditor.ITextModel | null { + for (const model of monacoEditor.getModels()) { + if (model.uri.toString() === uri) return model; + } + return null; + } + + // ── Document synchronization ──────────────────────────────────────────────── + + private openDocument(model: monacoEditor.ITextModel): void { + const uri = model.uri.toString(); + this.documentVersions.set(uri, model.getVersionId()); + this.sendNotification("textDocument/didOpen", { + textDocument: { + uri, + languageId: "lua", + version: model.getVersionId(), + text: model.getValue(), + }, + }); + } + + private changeDocument(model: monacoEditor.ITextModel): void { + const uri = model.uri.toString(); + const version = model.getVersionId(); + this.documentVersions.set(uri, version); + this.sendNotification("textDocument/didChange", { + textDocument: { uri, version }, + contentChanges: [{ text: model.getValue() }], + }); + } + + private closeDocument(model: monacoEditor.ITextModel): void { + const uri = model.uri.toString(); + this.documentVersions.delete(uri); + this.sendNotification("textDocument/didClose", { + textDocument: { uri }, + }); + monacoEditor.setModelMarkers(model, "lua-language-server", []); + } + + private attachModelListeners(): void { + // Listen for future model additions + const onAdd = monacoEditor.onDidCreateModel((model) => { + if (this.languageIds.includes(model.getLanguageId()) && this.initialized) { + this.openDocument(model); + } + }); + this.disposables.push(onAdd); + + // Track content changes for already-open models + const onDispose = monacoEditor.onWillDisposeModel((model) => { + if (this.documentVersions.has(model.uri.toString()) && this.initialized) { + this.closeDocument(model); + } + }); + this.disposables.push(onDispose); + + // Track content changes + const onChangeLanguage = monacoEditor.onDidChangeModelLanguage((event) => { + if (this.languageIds.includes(event.newLanguage) && this.initialized) { + this.openDocument(event.model); + } else if (this.documentVersions.has(event.model.uri.toString())) { + this.closeDocument(event.model); + } + }); + this.disposables.push(onChangeLanguage); + + // Subscribe to content changes for all relevant models + const syncContent = () => { + for (const model of monacoEditor.getModels()) { + if (this.languageIds.includes(model.getLanguageId())) { + const sub = model.onDidChangeContent(() => { + if (this.initialized) this.changeDocument(model); + }); + this.disposables.push(sub); + } + } + }; + syncContent(); + } + + // ── Monaco provider registration ──────────────────────────────────────────── + + private registerMonacoProviders(): void { + for (const langId of this.languageIds) { + // Completions + const completionProvider = monacoLanguages.registerCompletionItemProvider( + langId, + { + triggerCharacters: [".", ":"], + provideCompletionItems: async (model, position) => { + if (!this.initialized) return { suggestions: [] }; + try { + const result = await this.sendRequest( + "textDocument/completion", + { + textDocument: { uri: model.uri.toString() }, + position: toLspPosition(position), + }, + ) as { + items?: unknown[]; + isIncomplete?: boolean; + } | unknown[] | null; + + if (!result) return { suggestions: [] }; + + const items = Array.isArray(result) + ? result + : (result as { items?: unknown[] }).items ?? []; + + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const suggestions = (items as Array<{ + label: string | { label: string }; + kind?: number; + detail?: string; + documentation?: string | { kind: string; value: string }; + insertText?: string; + textEdit?: { newText: string; range: { start: { line: number; character: number }; end: { line: number; character: number } } }; + }>).map((item) => { + const label = + typeof item.label === "string" + ? item.label + : item.label.label; + const insertText = + item.textEdit?.newText ?? + item.insertText ?? + label; + const itemRange = item.textEdit + ? fromLspRange(item.textEdit.range) + : range; + + const docValue = + typeof item.documentation === "string" + ? item.documentation + : item.documentation?.value ?? item.detail ?? ""; + + return { + label, + kind: + LSP_COMPLETION_KIND_MAP[item.kind ?? 0] ?? + monacoLanguages.CompletionItemKind.Text, + detail: item.detail, + documentation: docValue + ? { value: docValue, isTrusted: false } + : undefined, + insertText, + range: itemRange, + } satisfies monacoLanguages.CompletionItem; + }); + + return { suggestions }; + } catch { + return { suggestions: [] }; + } + }, + }, + ); + this.disposables.push(completionProvider); + + // Hover + const hoverProvider = monacoLanguages.registerHoverProvider(langId, { + provideHover: async (model, position) => { + if (!this.initialized) return null; + try { + const result = await this.sendRequest("textDocument/hover", { + textDocument: { uri: model.uri.toString() }, + position: toLspPosition(position), + }) as { + contents: + | string + | { value: string; kind?: string } + | Array; + range?: { start: { line: number; character: number }; end: { line: number; character: number } }; + } | null; + + if (!result || !result.contents) return null; + + const contentsArray = Array.isArray(result.contents) + ? result.contents + : [result.contents]; + + const contents = contentsArray.map((c) => ({ + value: typeof c === "string" ? c : c.value, + isTrusted: false, + })); + + return { + contents, + range: result.range ? fromLspRange(result.range) : undefined, + }; + } catch { + return null; + } + }, + }); + this.disposables.push(hoverProvider); + } + } + + // ── JSON-RPC transport ─────────────────────────────────────────────────────── + + private sendRequest(method: string, params: unknown): Promise { + return new Promise((resolve, reject) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(new Error("WebSocket not open")); + return; + } + + const id = this.nextId++; + const msg: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; + this.pendingRequests.set(id, { resolve, reject }); + + // Timeout after 5 s to avoid hanging promises + const timer = setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error(`Request ${method} timed out`)); + } + }, 5000); + + this.pendingRequests.set(id, { + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + reject(e); + }, + }); + + try { + this.ws.send(JSON.stringify(msg)); + } catch (err) { + this.pendingRequests.delete(id); + clearTimeout(timer); + reject(err); + } + }); + } + + private sendNotification(method: string, params: unknown): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + const msg: JsonRpcNotification = { jsonrpc: "2.0", method, params }; + try { + this.ws.send(JSON.stringify(msg)); + } catch (err) { + console.warn(`[LuaLsClient] Failed to send notification ${method}:`, err); + } + } +} + +// ── Module-level singleton ──────────────────────────────────────────────────── + +let _client: LuaLsClient | null = null; + +/** + * Initialise and return the module-level LuaLS client. + * + * Call this once during application start-up (e.g. inside the MonacoEditor + * namespace initializer in monaco.ts). Safe to call multiple times — returns + * the existing client if already started. + * + * @param port The WebSocket port obtained from `window.electron.luaLanguageServer.getPort()`. + * Pass 0 (or omit) when running in a web build where LuaLS is not available. + */ +export async function initLuaLsClient(port: number): Promise { + if (port === 0) return null; + if (_client) return _client; + + _client = new LuaLsClient(port, ["intech_lua", "lua"]); + try { + await _client.start(); + console.info("[LuaLsClient] Connected and initialized."); + } catch (err) { + console.warn("[LuaLsClient] Failed to start:", err); + _client = null; + } + return _client; +} diff --git a/src/renderer/lib/monaco.ts b/src/renderer/lib/monaco.ts index fcddcd00a..b22d2bbf1 100644 --- a/src/renderer/lib/monaco.ts +++ b/src/renderer/lib/monaco.ts @@ -8,6 +8,7 @@ import { } from "monaco-editor"; import { TabFocus } from "monaco-editor/esm/vs/editor/browser/config/tabFocus.js"; import { ElementType, grid } from "@intechstudio/grid-protocol"; +import { initLuaLsClient } from "./lua-ls-client"; let hoverTips = {}; @@ -530,6 +531,18 @@ export namespace MonacoEditor { initialize_hover(); initialize_grammar(); + // Start the LuaLS client when running inside Electron. + // Falls back silently in the web build (port = 0 / API unavailable). + const luaLsPort: number = + typeof window !== "undefined" && + (window as any).electron?.luaLanguageServer?.getPort + ? (window as any).electron.luaLanguageServer.getPort() + : 0; + + initLuaLsClient(luaLsPort).catch((err) => { + console.warn("[MonacoEditor] LuaLS init failed:", err); + }); + export type Options = monaco_editor.IStandaloneEditorConstructionOptions; //export const fontSize = writable(get(appSettings).persistent.fontSize); From c389f8bb5125452a2795be3af81c24cabbcec2cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:54:47 +0000 Subject: [PATCH 3/3] Fix duplicate pendingRequests.set and improve JSON-RPC message type discrimination Co-authored-by: kkerti <47832952+kkerti@users.noreply.github.com> --- src/renderer/lib/lua-ls-client.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/renderer/lib/lua-ls-client.ts b/src/renderer/lib/lua-ls-client.ts index 5699e266a..95ce570cb 100644 --- a/src/renderer/lib/lua-ls-client.ts +++ b/src/renderer/lib/lua-ls-client.ts @@ -280,15 +280,15 @@ export class LuaLsClient { // ── Message dispatch ──────────────────────────────────────────────────────── private handleMessage(msg: JsonRpcMessage): void { - if ("id" in msg && "result" in msg) { - // Response to a request we sent + if ("id" in msg && !("method" in msg)) { + // Response to a request we sent (success or error) const pending = this.pendingRequests.get(msg.id as number); if (pending) { this.pendingRequests.delete(msg.id as number); - if (msg.error) { - pending.reject(new Error(msg.error.message)); + if ((msg as JsonRpcResponse).error) { + pending.reject(new Error((msg as JsonRpcResponse).error!.message)); } else { - pending.resolve(msg.result); + pending.resolve((msg as JsonRpcResponse).result); } } } else if ("method" in msg && !("id" in msg)) { @@ -558,7 +558,6 @@ export class LuaLsClient { const id = this.nextId++; const msg: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; - this.pendingRequests.set(id, { resolve, reject }); // Timeout after 5 s to avoid hanging promises const timer = setTimeout(() => {