From 94454968e56698092c8873a8bd792b72a6bb2f44 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Sun, 30 Nov 2025 04:39:33 +0100 Subject: [PATCH 1/8] Vibe-code LSP support --- package-lock.json | 81 +++++++++++++++++----- package.json | 1 + src/app/editor.ts | 21 +++++- src/app/lsp.ts | 155 ++++++++++++++++++++++++++++++++++++++++++ src/main/main.ts | 154 ++++++++++++++++++++++++++++++++++++++++- src/preload.ts | 39 +++++++++++ src/types/global.d.ts | 9 +++ 7 files changed, 440 insertions(+), 20 deletions(-) create mode 100644 src/app/lsp.ts 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; }; } } -- 2.44.2 From dc766cbfa347cbecffca4594a90ececb2917baed Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 1 Dec 2025 00:02:04 +0100 Subject: [PATCH 2/8] Fix keyboard shortcuts and remove llm garbage --- src/app/editor.ts | 18 ++++++++++++++---- src/app/lsp.ts | 43 ++++++++++++++----------------------------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/src/app/editor.ts b/src/app/editor.ts index d3eb9a4..6bd62f4 100644 --- a/src/app/editor.ts +++ b/src/app/editor.ts @@ -32,11 +32,18 @@ import { import { languages } from "@codemirror/language-data"; import { autocompletion, closeBrackets } from "@codemirror/autocomplete"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; +import { lintKeymap } from "@codemirror/lint"; import van from "vanjs-core"; import { Displayable } from "./displayable"; import { createLspExtension } from "./lsp"; import { OpenFile } from "./filestate"; +import { + findReferencesKeymap, + formatKeymap, + jumpToDefinitionKeymap, + renameKeymap, +} from "@codemirror/lsp-client"; const fixedHeightEditor = EditorView.theme({ "&": { @@ -97,6 +104,12 @@ export class Editor extends Displayable { ...defaultKeymap, ...searchKeymap, ...foldKeymap, + + ...lintKeymap, + ...jumpToDefinitionKeymap, + ...findReferencesKeymap, + ...formatKeymap, + ...renameKeymap, { key: "Mod-z", run: () => undo(file.target) }, { key: "Mod-shift-z", run: () => redo(file.target) }, { @@ -145,7 +158,6 @@ export class Editor extends Displayable { highlightActiveLineGutter(), highlightSelectionMatches(), indentUnit.of(" "), - // lintKeymap, ], }); @@ -165,9 +177,7 @@ export class Editor extends Displayable { // Kick off async creation, then reconfigure compartment when ready createLspExtension(p).then((ext: Extension) => { try { - const eff = this.lspCompartment.reconfigure( - ext as Extension, - ); + const eff = this.lspCompartment.reconfigure(ext); this.view.dispatch({ effects: [eff] }); } catch (err) { console.warn("Failed to apply LSP extension:", err); diff --git a/src/app/lsp.ts b/src/app/lsp.ts index fa2a0a9..ce6bf4a 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -3,6 +3,8 @@ import { Extension } from "@codemirror/state"; +import { LSPClient, languageServerExtensions } from "@codemirror/lsp-client"; + // Create a very small MessagePort-based transport implementation // compatible with @codemirror/lsp-client's expected Transport interface. async function simpleMessagePortTransport(port: MessagePort) { @@ -15,11 +17,8 @@ async function simpleMessagePortTransport(port: MessagePort) { }; 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() - } + port.start(); + return { send(message: string) { try { @@ -45,7 +44,7 @@ function filePathToUri(path: string) { const u = new URL("file://" + path); return u.toString(); } catch (err) { - // Fallback: naive replacement + console.warn("Failed to convert file path to URI via URL:", err); return "file://" + path; } } @@ -58,24 +57,6 @@ export async function createLspExtension( ): 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. @@ -121,33 +102,37 @@ export async function createLspExtension( // directory containing the file. let rootUri: string | undefined = undefined; try { - const ws = await (window as any).electronAPI.getCurrentWorkspace(); + 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) { - // ignore + console.warn("Failed to convert file path to URI via URL:", e); } } const client = new LSPClient({ extensions: languageServerExtensions(), rootUri: rootUri, - } as any); + }); + 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. - const conn = client.connect(transport); + 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. const uri = filePathToUri(filePath); - return conn.plugin(uri); + return client.plugin(uri); } catch (err) { console.warn("Failed to create LSP client plugin:", err); return []; -- 2.44.2 From c0fed59548a1f80d281629d1e7c7f0edb9a5121b Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 1 Dec 2025 02:09:21 +0100 Subject: [PATCH 3/8] 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; }; } } -- 2.44.2 From c44971ff23b1103392d5b3018b83820a8437390b Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 1 Dec 2025 02:29:05 +0100 Subject: [PATCH 4/8] Small cleanup --- src/app/filestate.ts | 18 ------------------ src/app/lsp.ts | 40 +++++++++++++++++++++------------------- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/src/app/filestate.ts b/src/app/filestate.ts index 9305d73..8f29e58 100644 --- a/src/app/filestate.ts +++ b/src/app/filestate.ts @@ -86,10 +86,6 @@ export class OpenFile implements WorkspaceFile { 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(); } @@ -221,20 +217,6 @@ export class OpenFile implements WorkspaceFile { 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 diff --git a/src/app/lsp.ts b/src/app/lsp.ts index 4002901..9540f73 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -1,7 +1,7 @@ // Minimal LSP integration helper for the editor. // Keeps all LSP-specific logic in one place so it's easy to review. -import { Extension, ChangeSet, Text } from "@codemirror/state"; +import { Extension, ChangeSet, TransactionSpec } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { @@ -85,7 +85,7 @@ export function inferLanguageFromPath( // expectations. This supports multiple views per OpenFile by using the // OpenFile.getView method. class OpenFileWorkspace extends Workspace { - files: WorkspaceFile[] = []; + files: OpenFile[] = []; private fileVersions: { [uri: string]: number } = Object.create(null); nextFileVersion(uri: string) { @@ -98,6 +98,7 @@ class OpenFileWorkspace extends Workspace { // Look through known workspace files and update their docs/versions // based on the editor views or the OpenFile state when no view exists. + // TODO: fix (cause vibe coding is useless) syncFiles() { let result: any[] = []; for (let file of this.files) { @@ -129,35 +130,36 @@ class OpenFileWorkspace extends Workspace { } openFile(uri: string, languageId: string, view: EditorView) { + console.log("LSP: attempting to open file", uri); 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); + if (!of) { + console.warn("LSP: attempted to open unknown file", uri); + return; + } + this.files.push(of); + this.client.didOpen(of); + } + + updateFile(uri: string, update: TransactionSpec): void { + const file = this.getFile(uri) as OpenFile; + if (!file) { + console.warn("LSP: attempted to update unknown file", uri); + return; + } + file.dispatch(update); } closeFile(uri: string, view: EditorView) { const path = uri.replace(/^file:\/\//, ""); const of = OpenFile.findOpenFile(path); // If OpenFile exists and still has editors, defer closing + console.log("LSP: attempting to close file", uri, of); if (of && of.editors.length > 0) return; this.files = this.files.filter((f) => f.uri !== uri); + console.log("LSP: closing file", uri); this.client.didClose(uri); } } -- 2.44.2 From 9f3befdb6144ff3aec24444391a13a5973315d32 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 1 Dec 2025 02:35:33 +0100 Subject: [PATCH 5/8] Fix file closing --- src/app/filestate.ts | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/app/filestate.ts b/src/app/filestate.ts index 8f29e58..526e743 100644 --- a/src/app/filestate.ts +++ b/src/app/filestate.ts @@ -97,7 +97,6 @@ export class OpenFile implements WorkspaceFile { 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 @@ -122,11 +121,10 @@ export class OpenFile implements WorkspaceFile { // Remove the editor from the list this.editors.splice(index, 1); + editor.view.destroy(); // If no more editors, remove from openFiles dictionary if (this.editors.length === 0) { - // Notify LSP that the document is closed - this.notifyLspClose(); openFiles.delete(this.filePath.val); } @@ -216,20 +214,4 @@ export class OpenFile implements WorkspaceFile { if (this.editors.length > 0) return this.editors[0].view; return null; } - - 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); - } - }); - } } -- 2.44.2 From aecff9f546ef4b359b21e35cc1797dff5adc57b6 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Mon, 1 Dec 2025 12:52:42 +0100 Subject: [PATCH 6/8] Fix state sync --- package-lock.json | 32 ++++++++++++++++---------------- package.json | 6 +++--- src/app/filestate.ts | 13 +++++++------ src/app/lsp.ts | 38 ++++++++++++-------------------------- 4 files changed, 38 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9974355..7d665ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "miller", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "miller", - "version": "0.1.0", + "version": "0.2.1", "license": "GPL-3.0-or-later", "dependencies": { - "chokidar": "^4.0.3", + "chokidar": "^5.0.0", "electron-squirrel-startup": "^1.0.1", "node-pty": "^1.1.0-beta39" }, @@ -37,7 +37,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "codemirror": "^6.0.2", - "electron": "39.1.1", + "electron": "39.2.4", "eslint": "^9.39.1", "eslint-plugin-import": "^2.32.0", "globals": "^16.5.0", @@ -4723,15 +4723,15 @@ "license": "MIT" }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -5253,9 +5253,9 @@ "license": "MIT" }, "node_modules/electron": { - "version": "39.1.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.1.1.tgz", - "integrity": "sha512-VuFEI1yQ7BH3RYI5VZtwFlzGp4rpPRd5oEc26ZQIItVLcLTbXt4/O7o4hs+1fyg9Q3NvGAifgX5Vp5EBOIFpAg==", + "version": "39.2.4", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.2.4.tgz", + "integrity": "sha512-KxPtwpFceQKSxRtUY39piHLYhJMMyHfOhc70e6zRnKGrbRdK6hzEqssth8IGjlKOdkeT4KCvIEngnNraYk39+g==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9921,12 +9921,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", diff --git a/package.json b/package.json index 005f38d..50f8e69 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "miller", "productName": "miller", - "version": "0.1.0", + "version": "0.2.1", "description": "Column-based code editor", "main": ".vite/build/main.js", "scripts": { @@ -42,7 +42,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "codemirror": "^6.0.2", - "electron": "39.1.1", + "electron": "39.2.4", "eslint": "^9.39.1", "eslint-plugin-import": "^2.32.0", "globals": "^16.5.0", @@ -55,7 +55,7 @@ "vite": "^7.2.2" }, "dependencies": { - "chokidar": "^4.0.3", + "chokidar": "^5.0.0", "electron-squirrel-startup": "^1.0.1", "node-pty": "^1.1.0-beta39" } diff --git a/src/app/filestate.ts b/src/app/filestate.ts index 526e743..343b17e 100644 --- a/src/app/filestate.ts +++ b/src/app/filestate.ts @@ -5,6 +5,7 @@ import { StateEffect, Text, Transaction, + ChangeSet, } from "@codemirror/state"; import { history } from "@codemirror/commands"; import { Editor } from "./editor"; @@ -159,10 +160,11 @@ export class OpenFile implements WorkspaceFile { 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 (this.changes === undefined) { + this.changes = ChangeSet.empty(this.rootState.val.doc.length); + } + this.changes = this.changes.compose(transaction.changes); } if (origin) { @@ -200,9 +202,8 @@ export class OpenFile implements WorkspaceFile { get languageId(): string { return inferLanguageFromPath(this.filePath.val || "") || ""; } - get doc(): Text { - return this.rootState.val.doc; - } + doc: Text; + changes: ChangeSet; // 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. diff --git a/src/app/lsp.ts b/src/app/lsp.ts index 9540f73..e378b54 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -8,8 +8,6 @@ import { LSPClient, languageServerExtensions, Workspace, - WorkspaceFile, - LSPPlugin, } from "@codemirror/lsp-client"; import { OpenFile } from "./filestate"; @@ -100,30 +98,16 @@ class OpenFileWorkspace extends Workspace { // based on the editor views or the OpenFile state when no view exists. // TODO: fix (cause vibe coding is useless) 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); - } + const result = []; + for (const file of this.files) { + const prevDoc = file.doc; + // TODO: get changes from rootState (tracked in OpenFile) rather than the view's LSPPlugin. + const changes = file.changes; + if (changes && !changes.empty) { + result.push({ file, prevDoc, changes }); + file.doc = file.rootState.val.doc; + file.version = this.nextFileVersion(file.uri); + file.changes = ChangeSet.empty(file.doc.length); } } return result; @@ -139,6 +123,7 @@ class OpenFileWorkspace extends Workspace { console.warn("LSP: attempted to open unknown file", uri); return; } + of.doc = view.state.doc; this.files.push(of); this.client.didOpen(of); } @@ -149,6 +134,7 @@ class OpenFileWorkspace extends Workspace { console.warn("LSP: attempted to update unknown file", uri); return; } + // TODO: maybe couple undos across editors for things like LSP rename? file.dispatch(update); } -- 2.44.2 From e898bd91f4024912d66093acaeeca8f376fc84ac Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Tue, 2 Dec 2025 20:52:39 +0100 Subject: [PATCH 7/8] Add keyboard-driven navigation within tabs --- src/app/displayable.ts | 2 +- src/app/editor.ts | 3 ++- src/app/editorgrid.ts | 36 ++++++++++++++++++++++-------------- src/app/lsp.ts | 2 -- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/app/displayable.ts b/src/app/displayable.ts index 4f8a7ef..b6a5796 100644 --- a/src/app/displayable.ts +++ b/src/app/displayable.ts @@ -22,7 +22,7 @@ export abstract class Displayable { setTimeout(() => this.installHandlers(0), 0); // Add general shortcuts - this.addShortcut("Ctrl-w", () => this.close()); + this.addShortcut("Alt-w", () => this.close()); this.addShortcut("Alt--", () => this.changeWidth(-100)); this.addShortcut("Alt-=", () => this.changeWidth(100)); } diff --git a/src/app/editor.ts b/src/app/editor.ts index 6bd62f4..a88c137 100644 --- a/src/app/editor.ts +++ b/src/app/editor.ts @@ -19,7 +19,7 @@ import { crosshairCursor, showPanel, } from "@codemirror/view"; -import { defaultKeymap, undo, redo } from "@codemirror/commands"; +import { defaultKeymap, undo, redo, indentWithTab } from "@codemirror/commands"; import { oneDark } from "@codemirror/theme-one-dark"; import { LanguageDescription, @@ -110,6 +110,7 @@ export class Editor extends Displayable { ...findReferencesKeymap, ...formatKeymap, ...renameKeymap, + indentWithTab, { key: "Mod-z", run: () => undo(file.target) }, { key: "Mod-shift-z", run: () => redo(file.target) }, { diff --git a/src/app/editorgrid.ts b/src/app/editorgrid.ts index 2ee262e..b36edff 100644 --- a/src/app/editorgrid.ts +++ b/src/app/editorgrid.ts @@ -17,28 +17,34 @@ const EditorWrapper = ( van.derive(() => { if (!editor || !editor.val) return; - const wrappedDelete = () => { - // TODO: find a better way to get the list containing this EditorWrapper + const findLeft = () => { const list = editors[currentTab.val] || []; - - // Find nearest non-empty neighbor (scan left then right) - let neighborState: Displayable | null = null; for (let i = k - 1; i >= 0; i--) { const c = list[i]; if (c) { - neighborState = c; - break; + return c; } } - if (!neighborState) { - for (let i = k + 1; i < list.length; i++) { - const c = list[i]; - if (c) { - neighborState = c; - break; - } + return null; + }; + + const findRight = () => { + const list = editors[currentTab.val] || []; + for (let i = k + 1; i < list.length; i++) { + const c = list[i]; + if (c) { + return c; } } + return null; + }; + + const wrappedDelete = () => { + // Find nearest non-empty neighbor (scan left then right) + let neighborState: Displayable | null = findLeft(); + if (!neighborState) { + neighborState = findRight(); + } // Call the original delete function which updates the reactive list / DOM del(); @@ -49,6 +55,8 @@ const EditorWrapper = ( } }; + editor.val.addShortcut("Alt-[", () => findLeft()?.focus()); + editor.val.addShortcut("Alt-]", () => findRight()?.focus()); editor.val.setDeleteFunction(wrappedDelete); }); diff --git a/src/app/lsp.ts b/src/app/lsp.ts index e378b54..50a98e3 100644 --- a/src/app/lsp.ts +++ b/src/app/lsp.ts @@ -96,12 +96,10 @@ class OpenFileWorkspace extends Workspace { // Look through known workspace files and update their docs/versions // based on the editor views or the OpenFile state when no view exists. - // TODO: fix (cause vibe coding is useless) syncFiles() { const result = []; for (const file of this.files) { const prevDoc = file.doc; - // TODO: get changes from rootState (tracked in OpenFile) rather than the view's LSPPlugin. const changes = file.changes; if (changes && !changes.empty) { result.push({ file, prevDoc, changes }); -- 2.44.2 From b738e9aab4dea8bfaeec60142728276ca620c145 Mon Sep 17 00:00:00 2001 From: Quinten Kock Date: Tue, 2 Dec 2025 21:25:45 +0100 Subject: [PATCH 8/8] Fix deprecation warnings --- package-lock.json | 25 +++++++++++++++++++++++++ src/main/langserver.ts | 8 ++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d665ad..6fc2ab3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5855,6 +5855,31 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/src/main/langserver.ts b/src/main/langserver.ts index be11010..35efbd9 100644 --- a/src/main/langserver.ts +++ b/src/main/langserver.ts @@ -76,17 +76,17 @@ function ensureLspForKey( while (true) { const headerEnd = entry.buffer.indexOf("\r\n\r\n"); if (headerEnd === -1) break; - const header = entry.buffer.slice(0, headerEnd).toString(); + const header = entry.buffer.subarray(0, headerEnd).toString(); const m = header.match(/Content-Length:\s*(\d+)/i); if (!m) { // Malformed, drop - entry.buffer = entry.buffer.slice(headerEnd + 4); + entry.buffer = entry.buffer.subarray(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(); + const body = entry.buffer.subarray(headerEnd + 4, totalLen).toString(); // Forward body to all attached ports try { entry.ports.forEach((p) => { @@ -103,7 +103,7 @@ function ensureLspForKey( } catch (err) { console.warn("Failed to forward LSP message to renderer", err); } - entry.buffer = entry.buffer.slice(totalLen); + entry.buffer = entry.buffer.subarray(totalLen); } }); -- 2.44.2