miller/src/app/lsp.ts

286 lines
9.6 KiB
TypeScript

// 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,
WorkspaceFile,
LSPPlugin,
} from "@codemirror/lsp-client";
import { OpenFile } from "./filestate";
// Create a very small MessagePort-based transport implementation
// compatible with @codemirror/lsp-client's expected Transport interface.
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.
// TODO: fix (cause vibe coding is useless)
syncFiles() {
let result: any[] = [];
for (let file of this.files) {
const view = file.getView?.();
if (view) {
const plugin = LSPPlugin.get(view);
if (!plugin) continue;
const changes = plugin.unsyncedChanges;
if (!changes.empty) {
result.push({ file, prevDoc: file.doc, changes });
file.doc = view.state.doc;
file.version = this.nextFileVersion(file.uri);
plugin.clear();
}
} else {
// No view; try to find a corresponding OpenFile and update
const path = file.uri.replace(/^file:\/\//, "");
const of = OpenFile.findOpenFile(path);
if (of && of.doc.toString() !== file.doc.toString()) {
const prev = file.doc;
const changes = ChangeSet.empty(prev.length);
result.push({ file, prevDoc: prev, changes });
file.doc = of.doc;
file.version = this.nextFileVersion(file.uri);
}
}
}
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;
}
this.files.push(of);
this.client.didOpen(of);
}
updateFile(uri: string, update: TransactionSpec): void {
const file = this.getFile(uri) as OpenFile;
if (!file) {
console.warn("LSP: attempted to update unknown file", uri);
return;
}
file.dispatch(update);
}
closeFile(uri: string, view: EditorView) {
const path = uri.replace(/^file:\/\//, "");
const of = OpenFile.findOpenFile(path);
// If OpenFile exists and still has editors, defer closing
console.log("LSP: attempting to close file", uri, of);
if (of && of.editors.length > 0) return;
this.files = this.files.filter((f) => f.uri !== uri);
console.log("LSP: closing file", uri);
this.client.didClose(uri);
}
}
// 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 [];
}
}