diff --git a/package-lock.json b/package-lock.json index 4c417c0..9974355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9a94df9..005f38d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/editor.ts b/src/app/editor.ts index 4428fd7..d3eb9a4 100644 --- a/src/app/editor.ts +++ b/src/app/editor.ts @@ -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, diff --git a/src/app/lsp.ts b/src/app/lsp.ts new file mode 100644 index 0000000..fa2a0a9 --- /dev/null +++ b/src/app/lsp.ts @@ -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 { + 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((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 []; + } +} diff --git a/src/main/main.ts b/src/main/main.ts index e7a43f2..332529f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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", diff --git a/src/preload.ts b/src/preload.ts index 63b4c69..768f16c 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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((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); + } + }); + }, }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index b54cb18..071ad54 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -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; }; } }