More vibe-coding (for lsp/Workspace)

This commit is contained in:
Quinten Kock 2025-12-01 02:09:21 +01:00
parent dc766cbfa3
commit c0fed59548
6 changed files with 494 additions and 211 deletions

View File

@ -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<string, OpenFile> = 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<string>;
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<OpenFile> {
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);
}
});
}
}

View File

@ -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<Extension> {
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<MessagePort>((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<MessagePort>((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) {

200
src/main/langserver.ts Normal file
View File

@ -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<MessagePortMain>;
};
const lspServers = new Map<string, LspEntry>();
// simple fallback mapping for a few languages — prefer env overrides
const fallbackServerForLanguage: Record<string, string | undefined> = {
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 };
},
);
}

View File

@ -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";
/// <reference types="./forge-vite-env.d.ts" />
// 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(

View File

@ -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<void>((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);
}

10
src/types/global.d.ts vendored
View File

@ -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>,
) => 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<void>;
connectLsp: (opts?: {
language?: string;
root?: string;
}) => Promise<any>;
};
}
}