diff --git a/package-lock.json b/package-lock.json index 4c417c0..6fc2ab3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,21 @@ { "name": "miller", - "version": "0.1.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "miller", - "version": "0.1.0", - "license": "MIT", + "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" }, "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", @@ -36,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", @@ -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", @@ -4711,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/" @@ -5241,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", @@ -5843,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", @@ -6129,7 +6166,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 +8686,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", @@ -9897,12 +9946,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", @@ -10309,7 +10358,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 +11213,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11374,7 +11421,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11565,7 +11611,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -11659,7 +11704,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11667,6 +11711,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..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": { @@ -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", @@ -41,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", @@ -54,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/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 4428fd7..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, @@ -32,10 +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({ "&": { @@ -80,6 +88,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]); @@ -95,6 +104,13 @@ export class Editor extends Displayable { ...defaultKeymap, ...searchKeymap, ...foldKeymap, + + ...lintKeymap, + ...jumpToDefinitionKeymap, + ...findReferencesKeymap, + ...formatKeymap, + ...renameKeymap, + indentWithTab, { key: "Mod-z", run: () => undo(file.target) }, { key: "Mod-shift-z", run: () => redo(file.target) }, { @@ -126,6 +142,7 @@ export class Editor extends Displayable { this.wordWrapCompartment.of(EditorView.lineWrapping), this.languageCompartment.of([]), + this.lspCompartment.of([]), lineNumbers(), highlightSpecialChars(), foldGutter(), @@ -142,7 +159,6 @@ export class Editor extends Displayable { highlightActiveLineGutter(), highlightSelectionMatches(), indentUnit.of(" "), - // lintKeymap, ], }); @@ -150,12 +166,26 @@ 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); + 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/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/filestate.ts b/src/app/filestate.ts index 3e035a4..343b17e 100644 --- a/src/app/filestate.ts +++ b/src/app/filestate.ts @@ -5,18 +5,23 @@ import { StateEffect, Text, Transaction, + ChangeSet, } from "@codemirror/state"; 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 +43,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 +61,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 +74,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? } @@ -114,10 +122,11 @@ export class OpenFile { // 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) { - delete openFiles[this.filePath.val]; + openFiles.delete(this.filePath.val); } callback(); @@ -150,6 +159,14 @@ export class OpenFile { dispatch(trs: TransactionSpec, origin?: Editor) { const transaction = this.rootState.val.update(trs); this.rootState.val = transaction.state; + + if (transaction.changes && !transaction.changes.empty) { + if (this.changes === undefined) { + this.changes = ChangeSet.empty(this.rootState.val.doc.length); + } + this.changes = this.changes.compose(transaction.changes); + } + if (origin) { const es = this.editors.filter((e) => e !== origin); es.forEach((e) => e.dispatch(e.view.state.update(trs), true)); @@ -175,4 +192,27 @@ 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 || "") || ""; + } + 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. + 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; + } } diff --git a/src/app/lsp.ts b/src/app/lsp.ts new file mode 100644 index 0000000..50a98e3 --- /dev/null +++ b/src/app/lsp.ts @@ -0,0 +1,269 @@ +// Minimal LSP integration helper for the editor. +// Keeps all LSP-specific logic in one place so it's easy to review. + +import { Extension, ChangeSet, TransactionSpec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; + +import { + LSPClient, + languageServerExtensions, + Workspace, +} 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. +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 + port.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) { + console.warn("Failed to convert file path to URI via URL:", err); + return "file://" + path; + } +} + +// 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: OpenFile[] = []; + 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() { + const result = []; + for (const file of this.files) { + const prevDoc = file.doc; + 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; + } + + 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); + if (!of) { + console.warn("LSP: attempted to open unknown file", uri); + return; + } + of.doc = view.state.doc; + 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; + } + // TODO: maybe couple undos across editors for things like LSP rename? + 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); + } +} + +// 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 []; + // Determine workspace root (filesystem path) and a file:// URI for LSP + let rootPath: string | undefined = undefined; + let rootUri: string | undefined = undefined; + try { + 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" && + e.data.serverKey === serverKey + ) { + 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); + }); + } catch (err) { + console.warn("Failed to receive LSP MessagePort:", err); + return []; + } + + let transport; + try { + 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), + }); + client.connect(transport); + await client.initializing; + + // Store client. + clients.set(serverKey, { client, transport }); + + const uri = filePathToUri(filePath); + return client.plugin(uri); + } catch (err) { + console.warn("Failed to create LSP client plugin:", err); + return []; + } +} diff --git a/src/main/langserver.ts b/src/main/langserver.ts new file mode 100644 index 0000000..35efbd9 --- /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.subarray(0, headerEnd).toString(); + const m = header.match(/Content-Length:\s*(\d+)/i); + if (!m) { + // Malformed, drop + 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.subarray(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.subarray(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 e7a43f2..53bcd71 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,14 +4,15 @@ import { handleReadFile, handleSaveFile, // handleCreateFile, - getCurrentWorkspace, getOpenedFiles, showConfirmDialog, getWorkspaceTree, + 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. @@ -104,6 +105,12 @@ app.whenReady().then(() => { }, ); + // Terminal handlers + + // LSP server manager moved to src/main/langserver.ts + // It is initialized below via setupLangServer(). + setupLangServer(); + // Terminal handlers ipcMain.handle( "terminal:create", diff --git a/src/preload.ts b/src/preload.ts index 63b4c69..891211d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -105,4 +105,45 @@ 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 (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). 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, payload) => { + const ports = event.ports as MessagePort[]; + if (ports && ports.length > 0) { + try { + // Transfer port into the page context along with metadata. + window.postMessage( + { + source: "electron-lsp", + serverKey: payload.serverKey, + language: payload.language, + }, + "*", + [ports[0]], + ); + resolve(); + } catch (err) { + reject(err); + } + } else { + reject(new Error("No MessagePort received from main")); + } + }); + try { + ipcRenderer.invoke("lsp:connect", opts); + } catch (err) { + reject(err); + } + }); + }, }); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index b54cb18..af0959d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -60,8 +60,23 @@ 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 + // 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: (opts?: { + language?: string; + root?: string; + }) => Promise; }; } }