More vibe-coding (for lsp/Workspace)
This commit is contained in:
parent
dc766cbfa3
commit
c0fed59548
|
|
@ -9,14 +9,18 @@ import {
|
||||||
import { history } from "@codemirror/commands";
|
import { history } from "@codemirror/commands";
|
||||||
import { Editor } from "./editor";
|
import { Editor } from "./editor";
|
||||||
import van, { State } from "vanjs-core";
|
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
|
// Helper: find an open file instance by path
|
||||||
static findOpenFile(path?: string): OpenFile | undefined {
|
static findOpenFile(path?: string): OpenFile | undefined {
|
||||||
if (!path) return undefined;
|
if (!path) return undefined;
|
||||||
return openFiles[path];
|
return openFiles.get(path);
|
||||||
}
|
}
|
||||||
filePath: State<string>;
|
filePath: State<string>;
|
||||||
editors: Editor[];
|
editors: Editor[];
|
||||||
|
|
@ -38,6 +42,9 @@ export class OpenFile {
|
||||||
this.expectedDiskContent = van.state(null);
|
this.expectedDiskContent = van.state(null);
|
||||||
this.knownDiskContent = 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(() => {
|
this.diskDiscrepancyMessage = van.derive(() => {
|
||||||
const expected = this.expectedDiskContent.val;
|
const expected = this.expectedDiskContent.val;
|
||||||
const known = this.knownDiskContent.val;
|
const known = this.knownDiskContent.val;
|
||||||
|
|
@ -53,8 +60,8 @@ export class OpenFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
static async openFile(filePath?: string): Promise<OpenFile> {
|
static async openFile(filePath?: string): Promise<OpenFile> {
|
||||||
if (filePath && openFiles[filePath]) {
|
if (filePath && openFiles.has(filePath)) {
|
||||||
return openFiles[filePath];
|
return openFiles.get(filePath)!;
|
||||||
}
|
}
|
||||||
const { content, path } = await window.electronAPI.readFile(filePath);
|
const { content, path } = await window.electronAPI.readFile(filePath);
|
||||||
const file = new OpenFile({ doc: content });
|
const file = new OpenFile({ doc: content });
|
||||||
|
|
@ -66,10 +73,10 @@ export class OpenFile {
|
||||||
|
|
||||||
private setPath(path: string) {
|
private setPath(path: string) {
|
||||||
if (this.filePath.val) {
|
if (this.filePath.val) {
|
||||||
delete openFiles[this.filePath.val];
|
openFiles.delete(this.filePath.val);
|
||||||
}
|
}
|
||||||
this.filePath.val = path;
|
this.filePath.val = path;
|
||||||
openFiles[path] = this;
|
openFiles.set(path, this);
|
||||||
// TODO: what if openFiles[path] already exists?
|
// TODO: what if openFiles[path] already exists?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +86,10 @@ export class OpenFile {
|
||||||
await window.electronAPI.saveFile(doc, this.filePath.val);
|
await window.electronAPI.saveFile(doc, this.filePath.val);
|
||||||
this.lastSaved.val = this.rootState.val.doc;
|
this.lastSaved.val = this.rootState.val.doc;
|
||||||
this.expectedDiskContent.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 {
|
} else {
|
||||||
await this.saveAs();
|
await this.saveAs();
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +101,7 @@ export class OpenFile {
|
||||||
this.setPath(path);
|
this.setPath(path);
|
||||||
this.lastSaved.val = this.rootState.val.doc;
|
this.lastSaved.val = this.rootState.val.doc;
|
||||||
this.expectedDiskContent.val = doc;
|
this.expectedDiskContent.val = doc;
|
||||||
|
this.notifyLspSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to create and return a new EditorView for this file
|
// 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 no more editors, remove from openFiles dictionary
|
||||||
if (this.editors.length === 0) {
|
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();
|
callback();
|
||||||
|
|
@ -150,6 +164,13 @@ export class OpenFile {
|
||||||
dispatch(trs: TransactionSpec, origin?: Editor) {
|
dispatch(trs: TransactionSpec, origin?: Editor) {
|
||||||
const transaction = this.rootState.val.update(trs);
|
const transaction = this.rootState.val.update(trs);
|
||||||
this.rootState.val = transaction.state;
|
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) {
|
if (origin) {
|
||||||
const es = this.editors.filter((e) => e !== origin);
|
const es = this.editors.filter((e) => e !== origin);
|
||||||
es.forEach((e) => e.dispatch(e.view.state.update(trs), true));
|
es.forEach((e) => e.dispatch(e.view.state.update(trs), true));
|
||||||
|
|
@ -175,4 +196,58 @@ export class OpenFile {
|
||||||
isDirty(): boolean {
|
isDirty(): boolean {
|
||||||
return !this.lastSaved.val.eq(this.rootState.val.doc);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
225
src/app/lsp.ts
225
src/app/lsp.ts
|
|
@ -1,9 +1,18 @@
|
||||||
// Minimal LSP integration helper for the editor.
|
// Minimal LSP integration helper for the editor.
|
||||||
// Keeps all LSP-specific logic in one place so it's easy to review.
|
// 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
|
// Create a very small MessagePort-based transport implementation
|
||||||
// compatible with @codemirror/lsp-client's expected Transport interface.
|
// 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`.
|
// Public helper: attempt to create an LSP extension for `filePath`.
|
||||||
// Returns an empty array (no-op extension) on failure so callers can safely
|
// Returns an empty array (no-op extension) on failure so callers can safely
|
||||||
// reconfigure their compartments with the returned value.
|
// reconfigure their compartments with the returned value.
|
||||||
|
|
@ -56,24 +169,71 @@ export async function createLspExtension(
|
||||||
filePath?: string,
|
filePath?: string,
|
||||||
): Promise<Extension> {
|
): Promise<Extension> {
|
||||||
if (!filePath) return [];
|
if (!filePath) return [];
|
||||||
|
// Determine workspace root (filesystem path) and a file:// URI for LSP
|
||||||
// Try to establish a transport via main process MessagePort. This will
|
let rootPath: string | undefined = undefined;
|
||||||
// cause main to spawn (or reuse) an LSP server and hand us a MessagePort
|
let rootUri: string | undefined = undefined;
|
||||||
// connected to it.
|
|
||||||
let transport;
|
|
||||||
try {
|
try {
|
||||||
// Request main process to create/attach an LSP server and transfer a
|
const ws = await window.electronAPI.getCurrentWorkspace();
|
||||||
// MessagePort into the page. The preload will `postMessage` the port
|
if (ws && ws.root) {
|
||||||
// into the page with `{ source: 'electron-lsp' }` when it's ready.
|
rootPath = ws.root;
|
||||||
await window.electronAPI.connectLsp();
|
rootUri = filePathToUri(ws.root);
|
||||||
const port = await new Promise<MessagePort>((resolve, reject) => {
|
}
|
||||||
|
} 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(() => {
|
const timeout = setTimeout(() => {
|
||||||
window.removeEventListener("message", onMessage);
|
window.removeEventListener("message", onMessage);
|
||||||
reject(new Error("Timed out waiting for LSP MessagePort"));
|
reject(new Error("Timed out waiting for LSP MessagePort"));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
function onMessage(e: MessageEvent) {
|
function onMessage(e: MessageEvent) {
|
||||||
try {
|
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;
|
const ports = e.ports;
|
||||||
if (ports && ports.length > 0) {
|
if (ports && ports.length > 0) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -89,48 +249,31 @@ export async function createLspExtension(
|
||||||
}
|
}
|
||||||
window.addEventListener("message", onMessage);
|
window.addEventListener("message", onMessage);
|
||||||
});
|
});
|
||||||
transport = await simpleMessagePortTransport(port);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to connect to LSP MessagePort:", err);
|
console.warn("Failed to receive LSP MessagePort:", err);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create client and connect
|
let transport;
|
||||||
try {
|
try {
|
||||||
// Determine a sensible rootUri for the workspace. Prefer the explicit
|
transport = await simpleMessagePortTransport(port);
|
||||||
// workspace root reported by the main process, otherwise use the
|
} catch (err) {
|
||||||
// directory containing the file.
|
console.warn("Failed to create transport from MessagePort:", err);
|
||||||
let rootUri: string | undefined = undefined;
|
return [];
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const client = new LSPClient({
|
const client = new LSPClient({
|
||||||
extensions: languageServerExtensions(),
|
extensions: languageServerExtensions(),
|
||||||
rootUri: rootUri,
|
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);
|
client.connect(transport);
|
||||||
|
|
||||||
await client.initializing;
|
await client.initializing;
|
||||||
|
|
||||||
// The client exposes a `plugin` method which yields an extension that
|
// Store client.
|
||||||
// wires up autocompletion, diagnostics, and other LSP features for a
|
clients.set(serverKey, { client, transport });
|
||||||
// given URI. We convert the local path to a file:// URI.
|
|
||||||
const uri = filePathToUri(filePath);
|
const uri = filePathToUri(filePath);
|
||||||
return client.plugin(uri);
|
return client.plugin(uri);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -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.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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/main/main.ts
153
src/main/main.ts
|
|
@ -1,5 +1,4 @@
|
||||||
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron";
|
import { app, BrowserWindow, ipcMain } from "electron";
|
||||||
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
|
|
||||||
import {
|
import {
|
||||||
handleOpenFolder,
|
handleOpenFolder,
|
||||||
handleReadFile,
|
handleReadFile,
|
||||||
|
|
@ -8,12 +7,12 @@ import {
|
||||||
getOpenedFiles,
|
getOpenedFiles,
|
||||||
showConfirmDialog,
|
showConfirmDialog,
|
||||||
getWorkspaceTree,
|
getWorkspaceTree,
|
||||||
getCurrentWorkspaceRoot,
|
|
||||||
getCurrentWorkspace,
|
getCurrentWorkspace,
|
||||||
} from "./fileOperations";
|
} from "./fileOperations";
|
||||||
import { terminalManager } from "./pty";
|
import { terminalManager } from "./pty";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import started from "electron-squirrel-startup";
|
import started from "electron-squirrel-startup";
|
||||||
|
import { setupLangServer } from "./langserver";
|
||||||
/// <reference types="./forge-vite-env.d.ts" />
|
/// <reference types="./forge-vite-env.d.ts" />
|
||||||
|
|
||||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||||
|
|
@ -108,151 +107,9 @@ app.whenReady().then(() => {
|
||||||
|
|
||||||
// Terminal handlers
|
// Terminal handlers
|
||||||
|
|
||||||
// LSP server manager: spawn a server per renderer and expose a MessagePort
|
// LSP server manager moved to src/main/langserver.ts
|
||||||
// to the renderer so it can communicate with the server using MessagePort
|
// It is initialized below via setupLangServer().
|
||||||
// instead of websockets.
|
setupLangServer();
|
||||||
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
|
// Terminal handlers
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
|
|
|
||||||
|
|
@ -109,23 +109,25 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
// LSP connect: request a MessagePort connected to a language server which
|
// LSP connect: request a MessagePort connected to a language server which
|
||||||
// is spawned in the main process. Returns a `MessagePort` that can be used
|
// is spawned in the main process. Returns a `MessagePort` that can be used
|
||||||
// for bidirectional communication (postMessage/onmessage).
|
// 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
|
// Request the main process for a MessagePort. When it arrives we
|
||||||
// transfer it into the page (main world) using window.postMessage so
|
// transfer it into the page (main world) using window.postMessage so
|
||||||
// the page can receive the actual MessagePort object (contextBridge
|
// the page can receive the actual MessagePort object (contextBridge
|
||||||
// does not allow direct transfer of MessagePort objects via return
|
// 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<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
ipcRenderer.once("lsp:port", (event) => {
|
ipcRenderer.once("lsp:port", (event, payload) => {
|
||||||
const ports = (event as any).ports as MessagePort[];
|
const ports = event.ports as MessagePort[];
|
||||||
if (ports && ports.length > 0) {
|
if (ports && ports.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Transfer port into the page context. The page must
|
// Transfer port into the page context along with metadata.
|
||||||
// listen for 'message' events and look for
|
window.postMessage(
|
||||||
// `e.data.source === 'electron-lsp'` to receive the
|
{
|
||||||
// port.
|
source: "electron-lsp",
|
||||||
(window as any).postMessage(
|
serverKey: payload.serverKey,
|
||||||
{ source: "electron-lsp" },
|
language: payload.language,
|
||||||
|
},
|
||||||
"*",
|
"*",
|
||||||
[ports[0]],
|
[ports[0]],
|
||||||
);
|
);
|
||||||
|
|
@ -138,7 +140,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
ipcRenderer.invoke("lsp:connect");
|
ipcRenderer.invoke("lsp:connect", opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,10 @@ declare global {
|
||||||
removeAllTerminalListeners: () => void;
|
removeAllTerminalListeners: () => void;
|
||||||
// Filesystem events
|
// Filesystem events
|
||||||
onFsEvent: (
|
onFsEvent: (
|
||||||
callback: (ev: { event: string; path: string }) => void,
|
callback: (ev: {
|
||||||
|
event: string;
|
||||||
|
path: string;
|
||||||
|
}) => void | Promise<void>,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
// Request that the main process create (or reuse) an LSP server and
|
// 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
|
// via `window.postMessage` and the page should listen for a
|
||||||
// message with `{ source: 'electron-lsp' }` and take the transferred
|
// message with `{ source: 'electron-lsp' }` and take the transferred
|
||||||
// port from `event.ports[0]`.
|
// port from `event.ports[0]`.
|
||||||
connectLsp: () => Promise<void>;
|
connectLsp: (opts?: {
|
||||||
|
language?: string;
|
||||||
|
root?: string;
|
||||||
|
}) => Promise<any>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue