Skip to content

Commit 8b456a8

Browse files
committed
feat: add LSP support for desktop (TypeScript/JavaScript via vtsls)
Add Language Server Protocol support to the editor, desktop-only (it depends on the Node layer to spawn server processes). The first server is vtsls, bundled in src-node so there is zero user setup. The framework is lazy-loaded so boot stays fast: the Node module is required on demand and the front end is pulled in after boot. All six features are capability-gated and degrade gracefully: completion, hover, jump-to-definition, signature help, diagnostics and find-references. Architecture - src-node/lsp-client.js: NodeConnector-backed ("ph-lsp") multi-server manager speaking JSON-RPC over stdio with Content-Length framing; resolves servers from node_modules/.bin then PATH. Not required at boot (lazy-loaded). - src/languageTools/LSPClient.js: browser-side client that exposes the method surface DefaultProviders expects, centralizes {line,ch}<->{character} and path<->file:// URI translation (including the /tauri virtual-path prefix), returns jQuery Deferreds, routes publishDiagnostics into CodeInspection, and auto-restarts a crashed server. Completion results are cached per context. - src/languageTools/DocumentSync.js: generic didOpen/didChange(debounced)/ didClose lifecycle. flush() now syncs on actual text difference rather than a pending debounce timer, fixing a race where a completion request (e.g. on ".") could reach the server before the keystroke, returning globals instead of members. - src/languageTools/HoverProvider.js + DocumentHighlight.js: hover docs via QuickViewManager and cursor-occurrence highlighting via documentHighlight. - src/extensions/default/TypeScriptSupport: thin per-language config; registered via DefaultExtensions.json desktopOnly. Code hints / insertion - DefaultProviders.insertHint anchors the replacement start on the server's textEdit.range.start (stable as you type forward; for members it points at the trigger "." whose dot is part of newText) and ends at the current cursor, fixing both "console..log" and the stale-cache "consolenso". - Snippet completions (insertTextFormat 2) are processed properly instead of inserting literal "$1": new reusable editor/TabstopManager expands the LSP snippet grammar ($1/$0, ${1:placeholder}, ${1|a,b|}, variables, escapes), places the caret at the first stop, and starts a Tab/Shift-Tab navigable session (marker-backed, so stops survive later edits such as an auto-import line). additionalTextEdits are applied for real auto-imports. The Emmet expander is left untouched with a pointer comment for future migration. - CodeHintList/CodeHintManager: scope row queries to li.code-hints-list-item so documentation-popup markup no longer pollutes the list, clear stale highlights on keyboard nav, and make session begin/update foolproof against duplicate lists. - KeyBindingManager: fall back to event.code when event.key is "Unidentified"/ "Dead"/empty so Ctrl-Space works under IME. UI - Theme-matched, opaque scrollbar for the codehint/inlinemenu dropdown; the app-wide transparent track previously let the editor show through the popup's scroll gutter. - Themed hover quickview and doc-popup styling.
1 parent 415619b commit 8b456a8

21 files changed

Lines changed: 2900 additions & 74 deletions

src-node/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ require("./test-connection");
7070
require("./utils");
7171
require("./terminal");
7272
require("./git/cli");
73+
// Note: "./lsp-client" is intentionally NOT required here. It is lazy-loaded on demand the
74+
// first time the desktop LSP client is used (via NodeUtils._loadNodeExtensionModule), so the
75+
// LSP framework adds nothing to node boot time. See src/languageTools/LSPClient.js.
7376
require("./claude-code-agent");
7477
function randomNonce(byteLength) {
7578
const randomBuffer = new Uint8Array(byteLength);

src-node/lsp-client.js

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it
7+
* under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
* for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
18+
*
19+
*/
20+
21+
/**
22+
* Pluggable LSP Client Infrastructure (Node side)
23+
*
24+
* Provides a reusable Language Server Protocol (LSP) client that supports multiple
25+
* language servers simultaneously via a serverId-based registry. Browser extensions only
26+
* configure their language server and make high-level calls; this module owns the process
27+
* spawning and JSON-RPC framing.
28+
*
29+
* ```
30+
* BROWSER (Phoenix) NODE (this module)
31+
* TypeScript/Python/Rust ─NodeConnector──▶ lsp-client.js ──stdio──▶ vtsls / pylsp / rust-analyzer
32+
* extensions ("ph-lsp") (spawn + JSON-RPC, Content-Length framing)
33+
* ```
34+
*
35+
* API (called from the browser via `lspConnector.execPeer(<fn>, params)`):
36+
* - startServer({ serverId, command, args=['--stdio'], rootUri }) -> { success, serverId, pid }
37+
* - sendRequest({ serverId, method, params }) -> LSP result (awaits response)
38+
* - sendNotification({ serverId, method, params }) -> { success } (fire and forget)
39+
* - stopServer({ serverId }) -> { success, serverId }
40+
* - listServers() -> [{ serverId, pid, rootUri }]
41+
* - ping() -> { status: "pong", activeServers }
42+
*
43+
* Events emitted to the browser (`lspConnector.on(<event>, ...)`):
44+
* - 'lspNotification' { serverId, method, params } (e.g. textDocument/publishDiagnostics)
45+
* - 'serverExit' { serverId, code }
46+
* - 'serverError' { serverId, error }
47+
*
48+
* Server resolution order when starting: `src-node/node_modules/.bin/<command>` (bundled),
49+
* then the system PATH. Messages use JSON-RPC 2.0 over stdio with Content-Length headers.
50+
*/
51+
52+
// Create connector at module load time (same pattern as src-node/git/cli.js)
53+
const nodeConnector = global.createNodeConnector("ph-lsp", exports);
54+
55+
const { spawn } = require('child_process');
56+
const path = require('path');
57+
const fs = require('fs');
58+
59+
// Path to node_modules/.bin for bundled LSP servers
60+
const NODE_MODULES_BIN = path.join(__dirname, 'node_modules', '.bin');
61+
62+
// Registry of active servers: serverId -> serverState
63+
const servers = new Map();
64+
let globalRequestId = 0;
65+
66+
// Timeout for LSP requests (2 minutes)
67+
const LSP_REQUEST_TIMEOUT = 120000;
68+
69+
/**
70+
* Encode a JSON-RPC message with an LSP Content-Length header.
71+
* @param {Object} message - The JSON-RPC message object
72+
* @returns {string} The encoded message with headers
73+
*/
74+
function encode(message) {
75+
const content = JSON.stringify(message);
76+
return `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n${content}`;
77+
}
78+
79+
/**
80+
* Create a stream parser for LSP messages from a specific server.
81+
* Uses Buffer operations because Content-Length is measured in bytes.
82+
* @param {string} serverId - The server identifier
83+
* @returns {Function} Parser function that processes incoming data chunks
84+
*/
85+
function createParser(serverId) {
86+
let buffer = Buffer.alloc(0);
87+
const HEADER_DELIMITER = Buffer.from('\r\n\r\n');
88+
89+
return (data) => {
90+
buffer = Buffer.concat([buffer, data]);
91+
92+
while (true) {
93+
const headerEnd = buffer.indexOf(HEADER_DELIMITER);
94+
if (headerEnd === -1) {
95+
break;
96+
}
97+
98+
const header = buffer.slice(0, headerEnd).toString('utf8');
99+
const match = header.match(/Content-Length: (\d+)/i);
100+
if (!match) {
101+
// Invalid header - skip a byte and resync.
102+
buffer = buffer.slice(1);
103+
continue;
104+
}
105+
106+
const contentLength = parseInt(match[1], 10);
107+
const contentStart = headerEnd + HEADER_DELIMITER.length;
108+
109+
if (buffer.length < contentStart + contentLength) {
110+
break; // Wait for more data.
111+
}
112+
113+
const json = buffer.slice(contentStart, contentStart + contentLength).toString('utf8');
114+
buffer = buffer.slice(contentStart + contentLength);
115+
116+
try {
117+
handleMessage(serverId, JSON.parse(json));
118+
} catch (e) {
119+
console.error(`[lsp-client][${serverId}] parse error:`, e.message);
120+
}
121+
}
122+
};
123+
}
124+
125+
/**
126+
* Handle a single incoming LSP message from a server.
127+
* @param {string} serverId - The server identifier
128+
* @param {Object} msg - The parsed JSON-RPC message
129+
*/
130+
function handleMessage(serverId, msg) {
131+
const server = servers.get(serverId);
132+
if (!server) {
133+
return;
134+
}
135+
136+
if (msg.id !== undefined && server.pending.has(msg.id)) {
137+
// Response to a request we sent.
138+
const { resolve, reject } = server.pending.get(msg.id);
139+
server.pending.delete(msg.id);
140+
if (msg.error) {
141+
reject(msg.error);
142+
} else {
143+
resolve(msg.result);
144+
}
145+
} else if (msg.method) {
146+
// Notification or server-initiated request - forward to the browser.
147+
nodeConnector.triggerPeer('lspNotification', { serverId, ...msg });
148+
}
149+
}
150+
151+
/**
152+
* Ping endpoint to verify the LSP connector is alive.
153+
* @returns {Promise<Object>} Status and list of active servers
154+
*/
155+
exports.ping = async function ping() {
156+
return { status: "pong", activeServers: Array.from(servers.keys()) };
157+
};
158+
159+
/**
160+
* Start a new language server.
161+
* @param {Object} params - Server configuration
162+
* @param {string} params.serverId - Unique identifier for this server instance
163+
* @param {string} params.command - Command used to spawn the language server
164+
* @param {string[]} [params.args=['--stdio']] - Arguments for the command
165+
* @param {string} params.rootUri - Root URI of the workspace
166+
* @returns {Promise<Object>} Result with success status and server info
167+
*/
168+
exports.startServer = async function startServer(params) {
169+
const { serverId, command, args = ['--stdio'], rootUri } = params;
170+
171+
if (!serverId || !command) {
172+
throw new Error('serverId and command are required');
173+
}
174+
175+
if (servers.has(serverId)) {
176+
return { success: true, message: "already running", serverId };
177+
}
178+
179+
// Prefer a server bundled in node_modules/.bin, otherwise fall back to PATH.
180+
let commandPath = command;
181+
const localBinPath = path.join(NODE_MODULES_BIN, command);
182+
if (fs.existsSync(localBinPath)) {
183+
commandPath = localBinPath;
184+
}
185+
186+
return new Promise((resolve, reject) => {
187+
const serverProcess = spawn(commandPath, args, { stdio: ['pipe', 'pipe', 'pipe'] });
188+
const parser = createParser(serverId);
189+
190+
const serverState = {
191+
process: serverProcess,
192+
pending: new Map(),
193+
rootUri,
194+
stderrTail: [] // keep the last few stderr lines to attach to crash reports
195+
};
196+
197+
let hasResolved = false;
198+
199+
serverProcess.stdout.on('data', parser);
200+
serverProcess.stderr.on('data', (data) => {
201+
const text = data.toString();
202+
serverState.stderrTail.push(text);
203+
if (serverState.stderrTail.length > 50) {
204+
serverState.stderrTail.shift();
205+
}
206+
console.error(`[lsp-client][${serverId} stderr]`, text.trimEnd());
207+
});
208+
209+
serverProcess.on('spawn', () => {
210+
servers.set(serverId, serverState);
211+
hasResolved = true;
212+
resolve({ success: true, serverId, pid: serverProcess.pid });
213+
});
214+
215+
serverProcess.on('exit', (code, signal) => {
216+
servers.delete(serverId);
217+
const stderr = serverState.stderrTail.join('');
218+
if (code) {
219+
console.error(`[lsp-client][${serverId}] exited code=${code} signal=${signal || 'none'}`);
220+
}
221+
nodeConnector.triggerPeer('serverExit', { serverId, code, signal, stderr });
222+
if (!hasResolved) {
223+
hasResolved = true;
224+
reject(new Error(`Server ${serverId} exited immediately with code ${code}` +
225+
(stderr ? `\n${stderr}` : '')));
226+
}
227+
});
228+
229+
serverProcess.on('error', (err) => {
230+
console.error(`[lsp-client][${serverId}] spawn error:`, err.message);
231+
servers.delete(serverId);
232+
nodeConnector.triggerPeer('serverError', { serverId, error: err.message });
233+
if (!hasResolved) {
234+
hasResolved = true;
235+
reject(new Error(`Failed to spawn ${serverId}: ${err.message}`));
236+
}
237+
});
238+
239+
// Guard in case the 'spawn' event never fires.
240+
setTimeout(() => {
241+
if (!hasResolved) {
242+
hasResolved = true;
243+
reject(new Error(`Timeout waiting for ${serverId} to start`));
244+
}
245+
}, 10000);
246+
});
247+
};
248+
249+
/**
250+
* Send an LSP request to a server and wait for the response.
251+
* @param {Object} params - Request parameters
252+
* @param {string} params.serverId - Target server identifier
253+
* @param {string} params.method - LSP method name
254+
* @param {Object} params.params - LSP request parameters
255+
* @returns {Promise<Object>} The LSP response result
256+
*/
257+
exports.sendRequest = async function sendRequest(params) {
258+
const { serverId, method, params: lspParams } = params;
259+
const server = servers.get(serverId);
260+
261+
if (!server) {
262+
throw new Error(`Server ${serverId} not running`);
263+
}
264+
265+
const id = ++globalRequestId;
266+
const msg = { jsonrpc: '2.0', id, method, params: lspParams };
267+
268+
return new Promise((resolve, reject) => {
269+
const timeoutId = setTimeout(() => {
270+
if (server.pending.has(id)) {
271+
server.pending.delete(id);
272+
reject(new Error(`Request ${method} timed out after ${LSP_REQUEST_TIMEOUT}ms`));
273+
}
274+
}, LSP_REQUEST_TIMEOUT);
275+
276+
server.pending.set(id, {
277+
resolve: (result) => {
278+
clearTimeout(timeoutId);
279+
resolve(result);
280+
},
281+
reject: (error) => {
282+
clearTimeout(timeoutId);
283+
reject(error);
284+
}
285+
});
286+
287+
server.process.stdin.write(encode(msg));
288+
});
289+
};
290+
291+
/**
292+
* Send an LSP notification to a server (no response expected).
293+
* @param {Object} params - Notification parameters
294+
* @param {string} params.serverId - Target server identifier
295+
* @param {string} params.method - LSP method name
296+
* @param {Object} params.params - LSP notification parameters
297+
* @returns {Promise<Object>} Success confirmation
298+
*/
299+
exports.sendNotification = async function sendNotification(params) {
300+
const { serverId, method, params: lspParams } = params;
301+
const server = servers.get(serverId);
302+
303+
if (!server) {
304+
throw new Error(`Server ${serverId} not running`);
305+
}
306+
307+
const msg = { jsonrpc: '2.0', method, params: lspParams };
308+
server.process.stdin.write(encode(msg));
309+
return { success: true };
310+
};
311+
312+
/**
313+
* Stop a running language server.
314+
* @param {Object} params - Stop parameters
315+
* @param {string} params.serverId - Server identifier to stop
316+
* @returns {Promise<Object>} Success confirmation
317+
*/
318+
exports.stopServer = async function stopServer(params) {
319+
const { serverId } = params;
320+
const server = servers.get(serverId);
321+
322+
if (server) {
323+
// Reject any in-flight requests so browser-side promises do not hang.
324+
for (const { reject } of server.pending.values()) {
325+
reject(new Error(`Server ${serverId} stopped`));
326+
}
327+
server.pending.clear();
328+
server.process.kill();
329+
servers.delete(serverId);
330+
}
331+
return { success: true, serverId };
332+
};
333+
334+
/**
335+
* List all active language servers.
336+
* @returns {Promise<Array>} Array of server info objects
337+
*/
338+
exports.listServers = async function listServers() {
339+
return Array.from(servers.entries()).map(([id, state]) => ({
340+
serverId: id,
341+
pid: state.process.pid,
342+
rootUri: state.rootUri
343+
}));
344+
};

0 commit comments

Comments
 (0)