Merge pull request 'Basic LSP support' (#2) from lsp into main

Reviewed-on: #2
This commit is contained in:
Quinten Kock 2025-12-02 21:26:56 +01:00
commit d6d49b0067
11 changed files with 747 additions and 64 deletions

138
package-lock.json generated
View File

@ -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",

View File

@ -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"
}

View File

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

View File

@ -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,

View File

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

View File

@ -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<string, OpenFile> = new Map();
export class OpenFile {
export class OpenFile implements WorkspaceFile {
// Helper: find an open file instance by path
static findOpenFile(path?: string): OpenFile | undefined {
if (!path) return undefined;
return openFiles[path];
return openFiles.get(path);
}
filePath: State<string>;
editors: Editor[];
@ -38,6 +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<OpenFile> {
if (filePath && openFiles[filePath]) {
return openFiles[filePath];
if (filePath && openFiles.has(filePath)) {
return openFiles.get(filePath)!;
}
const { content, path } = await window.electronAPI.readFile(filePath);
const file = new OpenFile({ doc: content });
@ -66,10 +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;
}
}

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

@ -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<Extension> {
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<MessagePort>((resolve, reject) => {
const timeout = setTimeout(() => {
window.removeEventListener("message", onMessage);
reject(new Error("Timed out waiting for LSP MessagePort"));
}, 5000);
function onMessage(e: MessageEvent) {
try {
if (
e.data &&
e.data.source === "electron-lsp" &&
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 [];
}
}

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

@ -0,0 +1,200 @@
import {
ipcMain,
MessageChannelMain,
BrowserWindow,
MessagePortMain,
} from "electron";
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import { getCurrentWorkspaceRoot } from "./fileOperations";
type LspEntry = {
proc: ChildProcessWithoutNullStreams;
buffer: Buffer;
ports: Set<MessagePortMain>;
};
const lspServers = new Map<string, LspEntry>();
// simple fallback mapping for a few languages — prefer env overrides
const fallbackServerForLanguage: Record<string, string | undefined> = {
typescript: "npx typescript-language-server --log-level 4 --stdio",
python: "pylsp",
};
function ensureLspForKey(
serverKey: string,
language: string | undefined,
root: string | undefined,
) {
if (lspServers.has(serverKey)) return lspServers.get(serverKey)!.proc;
// Determine command for this language
let raw: string | undefined = undefined;
if (language) {
const envKey = `LSP_SERVER_CMD_${language.toUpperCase()}`;
raw = process.env[envKey];
}
raw =
raw ||
process.env.LSP_SERVER_CMD ||
fallbackServerForLanguage[language || ""];
if (!raw)
throw new Error(
`No LSP server command configured for language=${language}`,
);
const parts = raw.trim().split(/\s+/);
const cmd = parts[0];
const args = parts.slice(1);
const cwd = root || getCurrentWorkspaceRoot() || process.cwd();
console.log(
"Starting LSP server:",
cmd,
args,
"cwd=",
cwd,
"serverKey=",
serverKey,
);
console.log("Current environment: ", process.env);
// Spawn using shell:true so PATH and shell resolution behave like a user shell.
const proc = spawn([cmd].concat(args).join(" "), {
stdio: ["pipe", "pipe", "pipe"],
cwd,
shell: true,
});
console.log("LSP server started with PID", proc.pid);
const entry: LspEntry = { proc, buffer: Buffer.alloc(0), ports: new Set() };
lspServers.set(serverKey, entry);
// Buffer stdout and parse LSP-framed messages (Content-Length headers)
proc.stdout.on("data", (chunk: Buffer) => {
entry.buffer = Buffer.concat([entry.buffer, Buffer.from(chunk)]);
// Try to parse as many messages as available
while (true) {
const headerEnd = entry.buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const header = entry.buffer.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 };
},
);
}

View File

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

View File

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

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

@ -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>,
) => 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<any>;
};
}
}