Vibe-code LSP support

This commit is contained in:
Quinten Kock 2025-11-30 04:39:33 +01:00
parent 2f3d640ffb
commit 94454968e5
7 changed files with 440 additions and 20 deletions

81
package-lock.json generated
View File

@ -7,7 +7,7 @@
"": {
"name": "miller",
"version": "0.1.0",
"license": "MIT",
"license": "GPL-3.0-or-later",
"dependencies": {
"chokidar": "^4.0.3",
"electron-squirrel-startup": "^1.0.1",
@ -15,6 +15,7 @@
},
"devDependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/lsp-client": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
@ -50,9 +51,9 @@
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.6",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -442,6 +443,23 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lsp-client": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lsp-client/-/lsp-client-6.2.1.tgz",
"integrity": "sha512-fjEkEc+H0kG60thaybj5+UpSnt49yAaTzOLSYZC2wlhwNAtDsWO2uZnE2AXiRGQxBVDQBvVj01MsX3F/0Vivjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/language": "^6.11.0",
"@codemirror/lint": "^6.8.5",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.0",
"@lezer/highlight": "^1.2.1",
"marked": "^15.0.12",
"vscode-languageserver-protocol": "^3.17.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@ -925,7 +943,6 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@ -2263,7 +2280,6 @@
"integrity": "sha512-yl43JD/86CIj3Mz5mvvLJqAOfIup7ncxfJ0Btnl0/v5TouVUyeEdcpknfgc+yMevS/48oH9WAkkw93m7otLb/A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^3.0.1",
"@inquirer/confirm": "^4.0.1",
@ -3526,7 +3542,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@ -3945,8 +3960,7 @@
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
@ -3975,7 +3989,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4445,7 +4458,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -6129,7 +6141,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -8650,6 +8661,19 @@
"node": ">=6"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@ -10309,7 +10333,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -11165,7 +11188,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -11374,7 +11396,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -11565,7 +11586,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -11659,7 +11679,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -11667,6 +11686,34 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"dev": true,
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",

View File

@ -21,6 +21,7 @@
"devDependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/lsp-client": "^6.2.0",
"@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-rpm": "^7.10.2",

View File

@ -34,6 +34,7 @@ import { autocompletion, closeBrackets } from "@codemirror/autocomplete";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import van from "vanjs-core";
import { Displayable } from "./displayable";
import { createLspExtension } from "./lsp";
import { OpenFile } from "./filestate";
@ -80,6 +81,7 @@ export class Editor extends Displayable {
private wordWrapCompartment = new Compartment();
private languageCompartment = new Compartment();
private lspCompartment = new Compartment();
dispatch(tr: Transaction, inhibitSync = false) {
this.view.update([tr]);
@ -126,6 +128,7 @@ export class Editor extends Displayable {
this.wordWrapCompartment.of(EditorView.lineWrapping),
this.languageCompartment.of([]),
this.lspCompartment.of([]),
lineNumbers(),
highlightSpecialChars(),
foldGutter(),
@ -150,12 +153,28 @@ export class Editor extends Displayable {
LanguageDescription.matchFilename(languages, file.filePath.val)
?.load()
.then((Lang) => {
// const eff = StateEffect.appendConfig.of(Lang);
const eff = this.languageCompartment.reconfigure(Lang);
this.view.dispatch({ effects: [eff] });
});
});
// Load LSP extension for this file path if possible. This is optional
// and fails silently if the lsp client or server is not available.
van.derive(() => {
const p = file.filePath.val;
// Kick off async creation, then reconfigure compartment when ready
createLspExtension(p).then((ext: Extension) => {
try {
const eff = this.lspCompartment.reconfigure(
ext as Extension,
);
this.view.dispatch({ effects: [eff] });
} catch (err) {
console.warn("Failed to apply LSP extension:", err);
}
});
});
van.derive(() => {
const effects = FileStatusEffect.of(
file.diskDiscrepancyMessage.val,

155
src/app/lsp.ts Normal file
View File

@ -0,0 +1,155 @@
// 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";
// Create a very small MessagePort-based transport implementation
// compatible with @codemirror/lsp-client's expected Transport interface.
async function simpleMessagePortTransport(port: MessagePort) {
let handlers: ((value: string) => void)[] = [];
const onMessage = (e: MessageEvent) => {
const d = e.data;
if (typeof d === "string") {
for (const h of handlers) h(d);
}
};
port.addEventListener("message", onMessage);
// The port must be started to begin receiving messages
try {
port.start();
} catch (err) {
// Some environments don't require explicit start()
}
return {
send(message: string) {
try {
port.postMessage(message);
} catch (err) {
console.warn("Failed to post message on MessagePort", err);
}
},
subscribe(handler: (value: string) => void) {
handlers.push(handler);
},
unsubscribe(handler: (value: string) => void) {
handlers = handlers.filter((h) => h !== handler);
},
};
}
// Given a local file path like "/home/user/proj/src/foo.ts" produce a
// file:// URI (required by many LSP setups).
function filePathToUri(path: string) {
// Prefer URL constructor to ensure proper escaping
try {
const u = new URL("file://" + path);
return u.toString();
} catch (err) {
// Fallback: naive replacement
return "file://" + path;
}
}
// 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.
export async function createLspExtension(
filePath?: string,
): Promise<Extension> {
if (!filePath) return [];
// Dynamic import so projects without the dependency won't fail at module
// load time. This also makes the LSP code optional at runtime.
let mod: any;
try {
// @ts-ignore - the lsp client is optional at runtime; avoid hard
// compile-time failures if it's not installed in all environments.
mod = await import("@codemirror/lsp-client");
} catch (err) {
console.warn("@codemirror/lsp-client not available:", err);
return [];
}
const { LSPClient, languageServerExtensions } = mod as any;
if (!LSPClient) {
console.warn("@codemirror/lsp-client did not export LSPClient");
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;
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 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") {
const ports = e.ports;
if (ports && ports.length > 0) {
clearTimeout(timeout);
window.removeEventListener("message", onMessage);
resolve(ports[0]);
}
}
} catch (err) {
clearTimeout(timeout);
window.removeEventListener("message", onMessage);
reject(err);
}
}
window.addEventListener("message", onMessage);
});
transport = await simpleMessagePortTransport(port);
} catch (err) {
console.warn("Failed to connect to LSP MessagePort:", err);
return [];
}
// Create client and connect
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 as any).electronAPI.getCurrentWorkspace();
if (ws && ws.root) rootUri = filePathToUri(ws.root);
} catch (e) {
// ignore and fall back
}
if (!rootUri) {
try {
const dir = filePath.replace(/\/[^\/]*$/, "");
rootUri = filePathToUri(dir);
} catch (e) {
// ignore
}
}
const client = new LSPClient({
extensions: languageServerExtensions(),
rootUri: rootUri,
} as any);
// Pass a client/connection config containing the rootUri. The librar
// accepts a config object; we use `as any` to avoid TS errors here.
const conn = client.connect(transport);
// 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.
const uri = filePathToUri(filePath);
return conn.plugin(uri);
} catch (err) {
console.warn("Failed to create LSP client plugin:", err);
return [];
}
}

View File

@ -1,13 +1,15 @@
import { app, BrowserWindow, ipcMain } from "electron";
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron";
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import {
handleOpenFolder,
handleReadFile,
handleSaveFile,
// handleCreateFile,
getCurrentWorkspace,
getOpenedFiles,
showConfirmDialog,
getWorkspaceTree,
getCurrentWorkspaceRoot,
getCurrentWorkspace,
} from "./fileOperations";
import { terminalManager } from "./pty";
import path from "node:path";
@ -104,6 +106,154 @@ 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;
});
// Terminal handlers
ipcMain.handle(
"terminal:create",

View File

@ -105,4 +105,43 @@ 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 () => {
// 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).
return new Promise<void>((resolve, reject) => {
ipcRenderer.once("lsp:port", (event) => {
const ports = (event as any).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" },
"*",
[ports[0]],
);
resolve();
} catch (err) {
reject(err);
}
} else {
reject(new Error("No MessagePort received from main"));
}
});
try {
ipcRenderer.invoke("lsp:connect");
} catch (err) {
reject(err);
}
});
},
});

View File

@ -62,6 +62,15 @@ declare global {
onFsEvent: (
callback: (ev: { event: string; path: string }) => void,
) => void;
// Request that the main process create (or reuse) an LSP server and
// transfer a MessagePort into the page context. Because the
// ContextBridge cannot directly return MessagePort objects, this
// function resolves once the port has been transferred to the page
// 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>;
};
}
}