From c0fed59548a1f80d281629d1e7c7f0edb9a5121b Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 1 Dec 2025 02:09:21 +0100 Subject: [PATCH] More vibe-coding (for lsp/Workspace) --- src/app/filestate.ts | 91 +++++++++++++++-- src/app/lsp.ts | 227 +++++++++++++++++++++++++++++++++-------- src/main/langserver.ts | 200 ++++++++++++++++++++++++++++++++++++ src/main/main.ts | 153 +-------------------------- src/preload.ts | 24 +++-- src/types/global.d.ts | 10 +- 6 files changed, 494 insertions(+), 211 deletions(-) create mode 100644 src/main/langserver.ts diff --git a/src/app/filestate.ts b/src/app/filestate.ts index 3e035a4..9305d73 100644 --- a/src/app/filestate.ts +++ b/src/app/filestate.ts @@ -9,14 +9,18 @@ import { import { history } from "@codemirror/commands"; import { Editor } from "./editor"; import van, { State } from "vanjs-core"; +import { WorkspaceFile } from "@codemirror/lsp-client"; +import { inferLanguageFromPath } from "./lsp"; +import { EditorView } from "@codemirror/view"; -const openFiles: { [path: string]: OpenFile } = {}; +// export const openFiles: { [path: string]: OpenFile } = {}; +export const openFiles: Map = new Map(); -export class OpenFile { +export class OpenFile implements WorkspaceFile { // Helper: find an open file instance by path static findOpenFile(path?: string): OpenFile | undefined { if (!path) return undefined; - return openFiles[path]; + return openFiles.get(path); } filePath: State; editors: Editor[]; @@ -38,6 +42,9 @@ export class OpenFile { this.expectedDiskContent = van.state(null); this.knownDiskContent = van.state(null); + // LSP version counter: starts at 1 when document is first created/opened + this.version = 1; + this.diskDiscrepancyMessage = van.derive(() => { const expected = this.expectedDiskContent.val; const known = this.knownDiskContent.val; @@ -53,8 +60,8 @@ export class OpenFile { } static async openFile(filePath?: string): Promise { - if (filePath && openFiles[filePath]) { - return openFiles[filePath]; + if (filePath && openFiles.has(filePath)) { + return openFiles.get(filePath)!; } const { content, path } = await window.electronAPI.readFile(filePath); const file = new OpenFile({ doc: content }); @@ -66,10 +73,10 @@ export class OpenFile { private setPath(path: string) { if (this.filePath.val) { - delete openFiles[this.filePath.val]; + openFiles.delete(this.filePath.val); } this.filePath.val = path; - openFiles[path] = this; + openFiles.set(path, this); // TODO: what if openFiles[path] already exists? } @@ -79,6 +86,10 @@ export class OpenFile { await window.electronAPI.saveFile(doc, this.filePath.val); this.lastSaved.val = this.rootState.val.doc; this.expectedDiskContent.val = doc; + // Notify LSP clients that the file was saved. The lsp plugin typically + // listens to EditorView changes and save events; nudging the views + // ensures any listeners pick up the final document state. + this.notifyLspSave(); } else { await this.saveAs(); } @@ -90,6 +101,7 @@ export class OpenFile { this.setPath(path); this.lastSaved.val = this.rootState.val.doc; this.expectedDiskContent.val = doc; + this.notifyLspSave(); } // Function to create and return a new EditorView for this file @@ -117,7 +129,9 @@ export class OpenFile { // If no more editors, remove from openFiles dictionary if (this.editors.length === 0) { - delete openFiles[this.filePath.val]; + // Notify LSP that the document is closed + this.notifyLspClose(); + openFiles.delete(this.filePath.val); } callback(); @@ -150,6 +164,13 @@ export class OpenFile { dispatch(trs: TransactionSpec, origin?: Editor) { const transaction = this.rootState.val.update(trs); this.rootState.val = transaction.state; + + // If the transaction introduced document changes, increment version + if (transaction.changes && !transaction.changes.empty) { + this.version = (this.version || 0) + 1; + // TODO: call LSP didChange notification helper here + } + if (origin) { const es = this.editors.filter((e) => e !== origin); es.forEach((e) => e.dispatch(e.view.state.update(trs), true)); @@ -175,4 +196,58 @@ export class OpenFile { isDirty(): boolean { return !this.lastSaved.val.eq(this.rootState.val.doc); } + + // LSP stuff + version: number; + get uri(): string | null { + if (!this.filePath.val) return null; + return `file://${this.filePath.val}`; + } + get languageId(): string { + return inferLanguageFromPath(this.filePath.val || "") || ""; + } + get doc(): Text { + return this.rootState.val.doc; + } + // Return an EditorView to be used by the LSP Workspace for position mapping. + // If `main` is provided and belongs to this open file, return it. Otherwise + // return the first available editor view, or null if none exist. + getView(main?: EditorView): EditorView | null { + if (main) { + const found = this.editors.find((e) => e.view === main); + if (found) return main; + } + if (this.editors.length > 0) return this.editors[0].view; + return null; + } + + // Lightweight helper to nudge LSP plugins on views after a save. This + // triggers a no-op dispatch on each view so that any view-bound listeners + // (including lsp-client's save/didSave handling) can observe the new state. + notifyLspSave() { + this.editors.forEach((e) => { + try { + // dispatch an empty transaction to trigger plugin observers + e.view.dispatch({}); + } catch (err) { + console.warn("Failed to notify LSP of save for view:", err); + } + }); + } + + notifyLspClose() { + // Some language clients respond to EditorView disposal/transactions; to be + // conservative, dispatch a no-op and then attempt to remove the LSP + // extension from each view so the plugin can observe closure. + this.editors.forEach((e) => { + try { + e.view.dispatch({}); + // Attempt to remove the LSP compartment extension if available. + // We cannot directly mutate another module's compartments here, + // but leaving an empty dispatch is a safe, low-impact notification. + } catch (err) { + console.warn("Failed to notify LSP of close for view:", err); + } + }); + } } diff --git a/src/app/lsp.ts b/src/app/lsp.ts index ce6bf4a..4002901 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -1,9 +1,18 @@ // Minimal LSP integration helper for the editor. // Keeps all LSP-specific logic in one place so it's easy to review. -import { Extension } from "@codemirror/state"; +import { Extension, ChangeSet, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; -import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client"; +import { + LSPClient, + languageServerExtensions, + Workspace, + WorkspaceFile, + LSPPlugin, +} from "@codemirror/lsp-client"; + +import { OpenFile } from "./filestate"; // Create a very small MessagePort-based transport implementation // compatible with @codemirror/lsp-client's expected Transport interface. @@ -49,6 +58,110 @@ function filePathToUri(path: string) { } } +// Map of active clients keyed by `${language}::${rootUri}` +const clients = new Map< + string, + { + client: LSPClient; + transport?: any; + } +>(); + +export function inferLanguageFromPath( + filePath: string | undefined, +): string | undefined { + if (!filePath) return undefined; + const m = filePath.match(/\.([^.\/]+)$/); + if (!m) return undefined; + const ext = m[1].toLowerCase(); + if (ext === "ts" || ext === "tsx" || ext === "js" || ext === "jsx") + return "typescript"; + if (ext === "py") return "python"; + // add more mappings as needed + return undefined; +} + +// Workspace implementation that maps our OpenFile model to the LSP client's +// expectations. This supports multiple views per OpenFile by using the +// OpenFile.getView method. +class OpenFileWorkspace extends Workspace { + files: WorkspaceFile[] = []; + private fileVersions: { [uri: string]: number } = Object.create(null); + + nextFileVersion(uri: string) { + return (this.fileVersions[uri] = (this.fileVersions[uri] ?? -1) + 1); + } + + constructor(client: LSPClient) { + super(client); + } + + // Look through known workspace files and update their docs/versions + // based on the editor views or the OpenFile state when no view exists. + syncFiles() { + let result: any[] = []; + for (let file of this.files) { + const view = file.getView?.(); + if (view) { + const plugin = LSPPlugin.get(view); + if (!plugin) continue; + const changes = plugin.unsyncedChanges; + if (!changes.empty) { + result.push({ file, prevDoc: file.doc, changes }); + file.doc = view.state.doc; + file.version = this.nextFileVersion(file.uri); + plugin.clear(); + } + } else { + // No view; try to find a corresponding OpenFile and update + const path = file.uri.replace(/^file:\/\//, ""); + const of = OpenFile.findOpenFile(path); + if (of && of.doc.toString() !== file.doc.toString()) { + const prev = file.doc; + const changes = ChangeSet.empty(prev.length); + result.push({ file, prevDoc: prev, changes }); + file.doc = of.doc; + file.version = this.nextFileVersion(file.uri); + } + } + } + return result; + } + + openFile(uri: string, languageId: string, view: EditorView) { + if (this.getFile(uri)) return; + // Try to map to an existing OpenFile instance, prefer using its doc + const path = uri.replace(/^file:\/\//, ""); + const of = OpenFile.findOpenFile(path); + const file: WorkspaceFile = of + ? { + uri, + languageId: of.languageId || languageId, + version: of.version, + doc: of.doc, + getView: (main?: EditorView) => of.getView(main ?? view), + } + : { + uri, + languageId, + version: this.nextFileVersion(uri), + doc: view.state.doc, + getView: () => view, + }; + this.files.push(file); + this.client.didOpen(file); + } + + closeFile(uri: string, view: EditorView) { + const path = uri.replace(/^file:\/\//, ""); + const of = OpenFile.findOpenFile(path); + // If OpenFile exists and still has editors, defer closing + if (of && of.editors.length > 0) return; + this.files = this.files.filter((f) => f.uri !== uri); + this.client.didClose(uri); + } +} + // Public helper: attempt to create an LSP extension for `filePath`. // Returns an empty array (no-op extension) on failure so callers can safely // reconfigure their compartments with the returned value. @@ -56,24 +169,71 @@ export async function createLspExtension( filePath?: string, ): Promise { if (!filePath) return []; - - // Try to establish a transport via main process MessagePort. This will - // cause main to spawn (or reuse) an LSP server and hand us a MessagePort - // connected to it. - let transport; + // Determine workspace root (filesystem path) and a file:// URI for LSP + let rootPath: string | undefined = undefined; + let rootUri: string | undefined = undefined; try { - // Request main process to create/attach an LSP server and transfer a - // MessagePort into the page. The preload will `postMessage` the port - // into the page with `{ source: 'electron-lsp' }` when it's ready. - await window.electronAPI.connectLsp(); - const port = await new Promise((resolve, reject) => { + const ws = await window.electronAPI.getCurrentWorkspace(); + if (ws && ws.root) { + rootPath = ws.root; + rootUri = filePathToUri(ws.root); + } + } catch (e) { + console.warn("Failed to get workspace root from main process:", e); + } + if (!rootPath) { + try { + const dir = filePath.replace(/\/[^\/]*$/, ""); + rootPath = dir; + rootUri = filePathToUri(dir); + } catch (e) { + console.warn("Failed to derive workspace dir from file path:", e); + } + } + + const language = inferLanguageFromPath(filePath); + const serverKey = `${language || "auto"}::${rootPath || ""}`; + + // Reuse existing client if available + if (clients.has(serverKey)) { + const entry = clients.get(serverKey)!; + await entry.client.initializing; + try { + const uri = filePathToUri(filePath); + const ext = entry.client.plugin(uri); + return ext; + } catch (err) { + console.warn( + "Failed to create LSP plugin from existing client:", + err, + ); + return []; + } + } + + // Otherwise request a new server/port from main and create a client + try { + // Pass a filesystem root path to main so it can set cwd correctly. + await window.electronAPI.connectLsp({ language, root: rootPath }); + } catch (err) { + console.warn("Failed to request LSP server from main:", err); + return []; + } + + let port: MessagePort; + try { + port = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { window.removeEventListener("message", onMessage); reject(new Error("Timed out waiting for LSP MessagePort")); }, 5000); function onMessage(e: MessageEvent) { try { - if (e.data && e.data.source === "electron-lsp") { + if ( + e.data && + e.data.source === "electron-lsp" && + e.data.serverKey === serverKey + ) { const ports = e.ports; if (ports && ports.length > 0) { clearTimeout(timeout); @@ -89,48 +249,31 @@ export async function createLspExtension( } window.addEventListener("message", onMessage); }); - transport = await simpleMessagePortTransport(port); } catch (err) { - console.warn("Failed to connect to LSP MessagePort:", err); + console.warn("Failed to receive LSP MessagePort:", err); return []; } - // Create client and connect + let transport; try { - // Determine a sensible rootUri for the workspace. Prefer the explicit - // workspace root reported by the main process, otherwise use the - // directory containing the file. - let rootUri: string | undefined = undefined; - try { - const ws = await window.electronAPI.getCurrentWorkspace(); - if (ws && ws.root) rootUri = filePathToUri(ws.root); - } catch (e) { - // ignore and fall back - console.warn("Failed to get workspace root from main process:", e); - } - if (!rootUri) { - try { - const dir = filePath.replace(/\/[^\/]*$/, ""); - rootUri = filePathToUri(dir); - } catch (e) { - console.warn("Failed to convert file path to URI via URL:", e); - } - } + transport = await simpleMessagePortTransport(port); + } catch (err) { + console.warn("Failed to create transport from MessagePort:", err); + return []; + } + try { const client = new LSPClient({ extensions: languageServerExtensions(), rootUri: rootUri, + workspace: (c) => new OpenFileWorkspace(c), }); - console.log("LSP client created with extensions:", client); - // Pass a client/connection config containing the rootUri. The librar - // accepts a config object; we use `as any` to avoid TS errors here. client.connect(transport); - await client.initializing; - // The client exposes a `plugin` method which yields an extension that - // wires up autocompletion, diagnostics, and other LSP features for a - // given URI. We convert the local path to a file:// URI. + // Store client. + clients.set(serverKey, { client, transport }); + const uri = filePathToUri(filePath); return client.plugin(uri); } catch (err) { diff --git a/src/main/langserver.ts b/src/main/langserver.ts new file mode 100644 index 0000000..be11010 --- /dev/null +++ b/src/main/langserver.ts @@ -0,0 +1,200 @@ +import { + ipcMain, + MessageChannelMain, + BrowserWindow, + MessagePortMain, +} from "electron"; +import { spawn, ChildProcessWithoutNullStreams } from "child_process"; +import { getCurrentWorkspaceRoot } from "./fileOperations"; + +type LspEntry = { + proc: ChildProcessWithoutNullStreams; + buffer: Buffer; + ports: Set; +}; + +const lspServers = new Map(); + +// simple fallback mapping for a few languages — prefer env overrides +const fallbackServerForLanguage: Record = { + typescript: "npx typescript-language-server --log-level 4 --stdio", + python: "pylsp", +}; + +function ensureLspForKey( + serverKey: string, + language: string | undefined, + root: string | undefined, +) { + if (lspServers.has(serverKey)) return lspServers.get(serverKey)!.proc; + + // Determine command for this language + let raw: string | undefined = undefined; + if (language) { + const envKey = `LSP_SERVER_CMD_${language.toUpperCase()}`; + raw = process.env[envKey]; + } + raw = + raw || + process.env.LSP_SERVER_CMD || + fallbackServerForLanguage[language || ""]; + if (!raw) + throw new Error( + `No LSP server command configured for language=${language}`, + ); + + const parts = raw.trim().split(/\s+/); + const cmd = parts[0]; + const args = parts.slice(1); + const cwd = root || getCurrentWorkspaceRoot() || process.cwd(); + console.log( + "Starting LSP server:", + cmd, + args, + "cwd=", + cwd, + "serverKey=", + serverKey, + ); + + console.log("Current environment: ", process.env); + + // Spawn using shell:true so PATH and shell resolution behave like a user shell. + const proc = spawn([cmd].concat(args).join(" "), { + stdio: ["pipe", "pipe", "pipe"], + cwd, + shell: true, + }); + console.log("LSP server started with PID", proc.pid); + const entry: LspEntry = { proc, buffer: Buffer.alloc(0), ports: new Set() }; + lspServers.set(serverKey, entry); + + // Buffer stdout and parse LSP-framed messages (Content-Length headers) + proc.stdout.on("data", (chunk: Buffer) => { + entry.buffer = Buffer.concat([entry.buffer, Buffer.from(chunk)]); + // Try to parse as many messages as available + while (true) { + const headerEnd = entry.buffer.indexOf("\r\n\r\n"); + if (headerEnd === -1) break; + const header = entry.buffer.slice(0, headerEnd).toString(); + const m = header.match(/Content-Length:\s*(\d+)/i); + if (!m) { + // Malformed, drop + entry.buffer = entry.buffer.slice(headerEnd + 4); + continue; + } + const len = parseInt(m[1], 10); + const totalLen = headerEnd + 4 + len; + if (entry.buffer.length < totalLen) break; // wait for more + const body = entry.buffer.slice(headerEnd + 4, totalLen).toString(); + // Forward body to all attached ports + try { + entry.ports.forEach((p) => { + try { + p.postMessage(body); + } catch (err) { + console.warn( + "Failed to post to port on serverKey", + serverKey, + err, + ); + } + }); + } catch (err) { + console.warn("Failed to forward LSP message to renderer", err); + } + entry.buffer = entry.buffer.slice(totalLen); + } + }); + + proc.stderr.on("data", (chunk: Buffer) => { + console.error("LSP stderr:", chunk.toString()); + }); + + proc.on("exit", (code, signal) => { + console.log("LSP server exited", code, signal, serverKey); + lspServers.delete(serverKey); + try { + // broadcast exit event to all renderers + BrowserWindow.getAllWindows().forEach((w) => + w.webContents.send("lsp:exit", { code, signal, serverKey }), + ); + } catch (err) { + console.warn("Failed to broadcast LSP exit event:", err); + } + }); + + return proc; +} + +export function setupLangServer() { + ipcMain.handle( + "lsp:connect", + async (event, opts?: { language?: string; root?: string }) => { + const sender = event.sender; + const language = opts?.language; + const root = + opts?.root || getCurrentWorkspaceRoot() || process.cwd(); + const publicKey = `${language || "auto"}::${root}`; // visible to renderer + const internalKey = `${sender.id}::${publicKey}`; // unique per renderer + + let proc: ChildProcessWithoutNullStreams; + try { + proc = ensureLspForKey(internalKey, language, root); + } catch (err) { + console.error("Failed to ensure LSP server:", err); + return { ok: false, error: (err as Error).message }; + } + + // Create a MessageChannelMain and hand one port to the renderer. + const { port1, port2 } = new MessageChannelMain(); + + // Ensure port1 is started (MessagePortMain has start()). + port1.start(); + + // When renderer posts a message on port1, forward to LSP server stdin. + port1.on("message", (arg) => { + let data: any = arg; + try { + if (arg && typeof arg === "object" && "data" in arg) + data = (arg as any).data; + } catch (e) { + data = arg; + } + if (typeof data === "string") { + // Wrap in Content-Length header + const buf = Buffer.from(data, "utf8"); + const header = Buffer.from( + `Content-Length: ${buf.length}\r\n\r\n`, + "utf8", + ); + try { + proc.stdin.write(Buffer.concat([header, buf])); + } catch (err) { + console.warn("Failed to write to LSP stdin", err); + } + } + }); + + // Attach this port to the server entry so stdout gets forwarded to it + const entry = lspServers.get(internalKey); + if (entry) entry.ports.add(port1); + + // Transfer port2 to renderer along with metadata (serverKey, language) + try { + event.sender.postMessage( + "lsp:port", + { serverKey: publicKey, language }, + [port2], + ); + } catch (err) { + console.error( + "Failed to send LSP MessagePort to renderer:", + err, + ); + return { ok: false, error: (err as Error).message }; + } + return { ok: true, serverKey: publicKey }; + }, + ); +} diff --git a/src/main/main.ts b/src/main/main.ts index 332529f..53bcd71 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,5 +1,4 @@ -import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron"; -import { spawn, ChildProcessWithoutNullStreams } from "child_process"; +import { app, BrowserWindow, ipcMain } from "electron"; import { handleOpenFolder, handleReadFile, @@ -8,12 +7,12 @@ import { getOpenedFiles, showConfirmDialog, getWorkspaceTree, - getCurrentWorkspaceRoot, getCurrentWorkspace, } from "./fileOperations"; import { terminalManager } from "./pty"; import path from "node:path"; import started from "electron-squirrel-startup"; +import { setupLangServer } from "./langserver"; /// // Handle creating/removing shortcuts on Windows when installing/uninstalling. @@ -108,151 +107,9 @@ app.whenReady().then(() => { // Terminal handlers - // LSP server manager: spawn a server per renderer and expose a MessagePort - // to the renderer so it can communicate with the server using MessagePort - // instead of websockets. - const lspServers = new Map< - number, - { - proc: ChildProcessWithoutNullStreams; - buffer: Buffer; - postMessage?: (msg: string) => void; - } - >(); - - function startLspForWebContents(sender: Electron.WebContents) { - const id = sender.id; - if (lspServers.has(id)) return lspServers.get(id)!.proc; - - // Allow overriding command via env LSP_SERVER_CMD (eg "typescript-language-server --stdio") - const raw = - process.env.LSP_SERVER_CMD || - "npx typescript-language-server --log-level 4 --stdio"; - const parts = raw.trim().split(/\s+/); - const cmd = parts[0]; - const args = parts.slice(1); - const cwd = getCurrentWorkspaceRoot() || process.cwd(); - console.log("Starting LSP server:", cmd, args, "cwd=", cwd); - - const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], cwd }); - const entry = { proc, buffer: Buffer.alloc(0) }; - lspServers.set(id, entry); - - // Buffer stdout and parse LSP-framed messages (Content-Length headers) - proc.stdout.on("data", (chunk: Buffer) => { - entry.buffer = Buffer.concat([entry.buffer, Buffer.from(chunk)]); - console.log("LSP stdout chunk:", chunk.toString()); - // Try to parse as many messages as available - while (true) { - const headerEnd = entry.buffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) break; - const header = entry.buffer.slice(0, headerEnd).toString(); - const m = header.match(/Content-Length:\s*(\d+)/i); - if (!m) { - // Malformed, drop - entry.buffer = entry.buffer.slice(headerEnd + 4); - continue; - } - const len = parseInt(m[1], 10); - const totalLen = headerEnd + 4 + len; - if (entry.buffer.length < totalLen) break; // wait for more - const body = entry.buffer - .slice(headerEnd + 4, totalLen) - .toString(); - // Forward body to renderer via the per-server postMessage callback - try { - if (entry.postMessage) entry.postMessage(body); - else sender.send("lsp:message", body); - } catch (err) { - console.warn( - "Failed to forward LSP message to renderer", - err, - ); - } - entry.buffer = entry.buffer.slice(totalLen); - } - }); - - proc.stderr.on("data", (chunk: Buffer) => { - console.error("LSP stderr:", chunk.toString()); - }); - - proc.on("exit", (code, signal) => { - console.log("LSP server exited", code, signal); - lspServers.delete(id); - try { - sender.send("lsp:exit", { code, signal }); - } catch (err) { - /* ignore */ - } - }); - - return proc; - } - - ipcMain.handle("lsp:connect", async (event) => { - // Start LSP server for this renderer - const sender = event.sender; - const proc = startLspForWebContents(sender); - - // Create a MessageChannelMain and hand one port to the renderer. - const { port1, port2 } = new MessageChannelMain(); - - // Ensure port1 is started (MessagePortMain has start()). - try { - port1.start?.(); - } catch (err) { - // ignore - } - - // When renderer posts a message on port1, forward to LSP server stdin. - // Support both shapes: some implementations emit an event-like object - // with `.data`, others deliver the message as the first arg. - port1.on("message", (arg) => { - let data: any = arg; - try { - if (arg && typeof arg === "object" && "data" in arg) - data = (arg as any).data; - } catch (e) { - data = arg; - } - if (typeof data === "string") { - // Wrap in Content-Length header - const buf = Buffer.from(data, "utf8"); - const header = Buffer.from( - `Content-Length: ${buf.length}\r\n\r\n`, - "utf8", - ); - try { - console.log("renderer -> LSP:", data); - proc.stdin.write(Buffer.concat([header, buf])); - } catch (err) { - console.warn("Failed to write to LSP stdin", err); - } - } - }); - - // Make stdout-forwarding use port1 if available - const entry = lspServers.get(sender.id) as any; - if (entry) - entry.postMessage = (msg: string) => { - console.log("LSP -> renderer:", msg); - try { - port1.postMessage(msg); - } catch (err) { - console.warn("Failed to post to port1:", err); - } - }; - - // Transfer port2 to renderer - try { - event.sender.postMessage("lsp:port", null, [port2]); - } catch (err) { - console.error("Failed to send LSP MessagePort to renderer:", err); - return false; - } - return true; - }); + // LSP server manager moved to src/main/langserver.ts + // It is initialized below via setupLangServer(). + setupLangServer(); // Terminal handlers ipcMain.handle( diff --git a/src/preload.ts b/src/preload.ts index 768f16c..891211d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -109,23 +109,25 @@ contextBridge.exposeInMainWorld("electronAPI", { // LSP connect: request a MessagePort connected to a language server which // is spawned in the main process. Returns a `MessagePort` that can be used // for bidirectional communication (postMessage/onmessage). - connectLsp: async () => { + connectLsp: async (opts?: { language?: string; root?: string }) => { // Request the main process for a MessagePort. When it arrives we // transfer it into the page (main world) using window.postMessage so // the page can receive the actual MessagePort object (contextBridge // does not allow direct transfer of MessagePort objects via return - // values). + // values). The main process will include metadata `{ serverKey, language }` + // when posting the port so we can forward that on to the page. return new Promise((resolve, reject) => { - ipcRenderer.once("lsp:port", (event) => { - const ports = (event as any).ports as MessagePort[]; + ipcRenderer.once("lsp:port", (event, payload) => { + const ports = event.ports as MessagePort[]; if (ports && ports.length > 0) { try { - // Transfer port into the page context. The page must - // listen for 'message' events and look for - // `e.data.source === 'electron-lsp'` to receive the - // port. - (window as any).postMessage( - { source: "electron-lsp" }, + // Transfer port into the page context along with metadata. + window.postMessage( + { + source: "electron-lsp", + serverKey: payload.serverKey, + language: payload.language, + }, "*", [ports[0]], ); @@ -138,7 +140,7 @@ contextBridge.exposeInMainWorld("electronAPI", { } }); try { - ipcRenderer.invoke("lsp:connect"); + ipcRenderer.invoke("lsp:connect", opts); } catch (err) { reject(err); } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 071ad54..af0959d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -60,7 +60,10 @@ declare global { removeAllTerminalListeners: () => void; // Filesystem events onFsEvent: ( - callback: (ev: { event: string; path: string }) => void, + callback: (ev: { + event: string; + path: string; + }) => void | Promise, ) => void; // Request that the main process create (or reuse) an LSP server and @@ -70,7 +73,10 @@ declare global { // via `window.postMessage` and the page should listen for a // message with `{ source: 'electron-lsp' }` and take the transferred // port from `event.ports[0]`. - connectLsp: () => Promise; + connectLsp: (opts?: { + language?: string; + root?: string; + }) => Promise; }; } }