1066 lines
41 KiB
TypeScript
1066 lines
41 KiB
TypeScript
import type { editor as monacoEditor, IDisposable, languages, MarkerSeverity as MarkerSeverityType } from 'monaco-editor';
|
|
import { getBackendConfig } from '@/lib/config';
|
|
import { getMonacoInstance } from '@/components/editors/monaco/monaco-init';
|
|
import { setBuiltinTsSuppressed } from '@/components/editors/monaco/builtin-providers';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type LspStatus =
|
|
| { state: 'disabled' }
|
|
| { state: 'connecting' }
|
|
| { state: 'installing' }
|
|
| { state: 'starting' }
|
|
| { state: 'ready' }
|
|
| { state: 'stopping' }
|
|
| { state: 'unavailable'; reason: string }
|
|
| { state: 'error'; reason: string };
|
|
|
|
const DISABLED_STATUS: LspStatus = { state: 'disabled' };
|
|
const LSP_IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes
|
|
|
|
// Default per-language LSP server configurations.
|
|
// These are sent to the language server via workspace/configuration requests.
|
|
// Users can override these via lspServerConfigs in user settings.
|
|
export const LSP_DEFAULT_CONFIGS: Record<string, Record<string, unknown>> = {
|
|
go: { 'ui.semanticTokens': true },
|
|
};
|
|
|
|
type StatusListener = (key: string, status: LspStatus) => void;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal JSON-RPC 2.0 client over WebSocket
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type PendingRequest = { resolve: (value: unknown) => void; reject: (reason: unknown) => void };
|
|
|
|
class JsonRpcConnection {
|
|
private nextId = 1;
|
|
private pending = new Map<number, PendingRequest>();
|
|
private notificationHandlers = new Map<string, (params: unknown) => void>();
|
|
private requestHandlers = new Map<string, (params: unknown) => unknown>();
|
|
private messageHandler: ((event: MessageEvent) => void) | null = null;
|
|
|
|
constructor(private ws: WebSocket) {}
|
|
|
|
listen() {
|
|
this.messageHandler = (event: MessageEvent) => {
|
|
let msg: { jsonrpc?: string; id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown };
|
|
try {
|
|
msg = JSON.parse(event.data as string);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (msg.id !== undefined && msg.method !== undefined) {
|
|
// Server → client request
|
|
const handler = this.requestHandlers.get(msg.method);
|
|
if (handler) {
|
|
try {
|
|
const result = handler(msg.params);
|
|
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: result ?? null }));
|
|
} catch (err) {
|
|
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: String(err) } }));
|
|
}
|
|
} else {
|
|
// Respond with empty result for unhandled server requests (e.g. workspace/configuration)
|
|
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: null }));
|
|
}
|
|
} else if (msg.id !== undefined) {
|
|
// Response to our request
|
|
const p = this.pending.get(msg.id);
|
|
if (p) {
|
|
this.pending.delete(msg.id);
|
|
if (msg.error) p.reject(msg.error);
|
|
else p.resolve(msg.result);
|
|
}
|
|
} else if (msg.method !== undefined) {
|
|
// Notification from server
|
|
this.notificationHandlers.get(msg.method)?.(msg.params);
|
|
}
|
|
};
|
|
this.ws.addEventListener('message', this.messageHandler);
|
|
}
|
|
|
|
sendRequest(method: string, params: unknown): Promise<unknown> {
|
|
const id = this.nextId++;
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(id, { resolve, reject });
|
|
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
|
});
|
|
}
|
|
|
|
sendNotification(method: string, params: unknown): void {
|
|
this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params }));
|
|
}
|
|
|
|
onNotification(method: string, handler: (params: unknown) => void): void {
|
|
this.notificationHandlers.set(method, handler);
|
|
}
|
|
|
|
onRequest(method: string, handler: (params: unknown) => unknown): void {
|
|
this.requestHandlers.set(method, handler);
|
|
}
|
|
|
|
dispose() {
|
|
if (this.messageHandler) {
|
|
this.ws.removeEventListener('message', this.messageHandler);
|
|
this.messageHandler = null;
|
|
}
|
|
for (const p of this.pending.values()) {
|
|
p.reject(new Error('Connection disposed'));
|
|
}
|
|
this.pending.clear();
|
|
this.notificationHandlers.clear();
|
|
this.requestHandlers.clear();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LSP ↔ Monaco type conversions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LspPosition = { line: number; character: number };
|
|
type LspRange = { start: LspPosition; end: LspPosition };
|
|
|
|
function toMonacoRange(r: LspRange): { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number } {
|
|
return {
|
|
startLineNumber: r.start.line + 1,
|
|
startColumn: r.start.character + 1,
|
|
endLineNumber: r.end.line + 1,
|
|
endColumn: r.end.character + 1,
|
|
};
|
|
}
|
|
|
|
function toLspPosition(lineNumber: number, column: number): LspPosition {
|
|
return { line: lineNumber - 1, character: column - 1 };
|
|
}
|
|
|
|
// LSP CompletionItemKind → Monaco CompletionItemKind
|
|
function toMonacoCompletionKind(lspKind: number | undefined): number {
|
|
// Monaco CompletionItemKind values (approximate mapping)
|
|
const map: Record<number, number> = {
|
|
1: 14, // Text → Value
|
|
2: 1, // Method → Method
|
|
3: 0, // Function → Function
|
|
4: 8, // Constructor → Constructor
|
|
5: 4, // Field → Field
|
|
6: 5, // Variable → Variable
|
|
7: 7, // Class → Class
|
|
8: 7, // Interface → Interface (use Class icon)
|
|
9: 8, // Module → Module
|
|
10: 9, // Property → Property
|
|
11: 12, // Unit → Unit
|
|
12: 14, // Value → Value
|
|
13: 15, // Enum → Enum
|
|
14: 17, // Keyword → Keyword
|
|
15: 27, // Snippet → Snippet
|
|
16: 19, // Color → Color
|
|
17: 20, // File → File
|
|
18: 21, // Reference → Reference
|
|
19: 23, // Folder → Folder
|
|
20: 16, // EnumMember → EnumMember
|
|
21: 14, // Constant → Value
|
|
22: 6, // Struct → Struct
|
|
23: 24, // Event → Event
|
|
24: 25, // Operator → Operator
|
|
25: 26, // TypeParameter → TypeParameter
|
|
};
|
|
return map[lspKind ?? 1] ?? 14;
|
|
}
|
|
|
|
// LSP DiagnosticSeverity → Monaco MarkerSeverity
|
|
function toMonacoSeverity(lspSeverity: number | undefined): MarkerSeverityType {
|
|
const monaco = getMonacoInstance();
|
|
if (!monaco) return 8 as MarkerSeverityType; // Error fallback
|
|
switch (lspSeverity) {
|
|
case 1: return monaco.MarkerSeverity.Error;
|
|
case 2: return monaco.MarkerSeverity.Warning;
|
|
case 3: return monaco.MarkerSeverity.Info;
|
|
case 4: return monaco.MarkerSeverity.Hint;
|
|
default: return monaco.MarkerSeverity.Info;
|
|
}
|
|
}
|
|
|
|
// Extract plaintext from LSP MarkupContent or string
|
|
function extractHoverContents(contents: unknown): { value: string }[] {
|
|
if (typeof contents === 'string') return [{ value: contents }];
|
|
if (contents && typeof contents === 'object' && 'value' in contents) {
|
|
const c = contents as { kind?: string; value: string };
|
|
return [{ value: c.value }];
|
|
}
|
|
if (Array.isArray(contents)) {
|
|
return contents.map((item) => {
|
|
if (typeof item === 'string') return { value: item };
|
|
if (item && typeof item === 'object' && 'value' in item) return { value: (item as { value: string }).value };
|
|
return { value: String(item) };
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LSP Connection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type OpenDocument = { version: number; languageId: string };
|
|
|
|
type LSPConnection = {
|
|
ws: WebSocket;
|
|
rpc: JsonRpcConnection | null;
|
|
initialized: boolean;
|
|
refCount: number;
|
|
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
openDocuments: Map<string, OpenDocument>;
|
|
providerDisposables: IDisposable[];
|
|
serverCapabilities: Record<string, unknown> | null;
|
|
workspacePath: string | null;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getWsBaseUrl(): string {
|
|
try {
|
|
const backendUrl = getBackendConfig().apiBaseUrl;
|
|
const url = new URL(backendUrl);
|
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${url.host}`;
|
|
} catch {
|
|
return 'ws://localhost:8080';
|
|
}
|
|
}
|
|
|
|
class LSPClientManager {
|
|
private connections = new Map<string, LSPConnection>();
|
|
private statuses = new Map<string, LspStatus>();
|
|
private listeners = new Set<StatusListener>();
|
|
private fileOpener: ((uri: string, line?: number, column?: number) => void) | null = null;
|
|
/** Tracks placeholder Monaco models created for LSP references/definitions. */
|
|
private placeholderModels = new Set<string>();
|
|
|
|
setFileOpener(opener: ((uri: string, line?: number, column?: number) => void) | null): void {
|
|
this.fileOpener = opener;
|
|
}
|
|
|
|
getFileOpener(): ((uri: string, line?: number, column?: number) => void) | null {
|
|
return this.fileOpener;
|
|
}
|
|
|
|
// ---- localStorage persistence for manual LSP toggle ----
|
|
|
|
private lspStorageKey(sessionId: string, language: string): string {
|
|
return `kandev-lsp:${sessionId}:${language}`;
|
|
}
|
|
|
|
/** Save that LSP was manually enabled for this session+language. */
|
|
saveEnabledState(sessionId: string, language: string): void {
|
|
try { localStorage.setItem(this.lspStorageKey(sessionId, language), '1'); } catch {}
|
|
}
|
|
|
|
/** Clear the saved LSP state (manual stop). */
|
|
clearEnabledState(sessionId: string, language: string): void {
|
|
try { localStorage.removeItem(this.lspStorageKey(sessionId, language)); } catch {}
|
|
}
|
|
|
|
/** Check if LSP was previously enabled for this session+language. */
|
|
isEnabledInStorage(sessionId: string, language: string): boolean {
|
|
try { return localStorage.getItem(this.lspStorageKey(sessionId, language)) === '1'; } catch { return false; }
|
|
}
|
|
|
|
getStatus(sessionId: string, lspLanguage: string): LspStatus {
|
|
const key = `${sessionId}:${lspLanguage}`;
|
|
return this.statuses.get(key) ?? DISABLED_STATUS;
|
|
}
|
|
|
|
onStatusChange(listener: StatusListener): () => void {
|
|
this.listeners.add(listener);
|
|
return () => { this.listeners.delete(listener); };
|
|
}
|
|
|
|
private setStatus(key: string, status: LspStatus) {
|
|
this.statuses.set(key, status);
|
|
for (const listener of this.listeners) {
|
|
listener(key, status);
|
|
}
|
|
}
|
|
|
|
// ------- Connection lifecycle -------
|
|
|
|
connect(sessionId: string, lspLanguage: string, userConfigs?: Record<string, Record<string, unknown>>): () => void {
|
|
const key = `${sessionId}:${lspLanguage}`;
|
|
|
|
const existing = this.connections.get(key);
|
|
if (existing && existing.ws.readyState <= WebSocket.OPEN) {
|
|
existing.refCount++;
|
|
if (existing.idleTimer) {
|
|
clearTimeout(existing.idleTimer);
|
|
existing.idleTimer = null;
|
|
}
|
|
return () => this.decrementRef(key);
|
|
}
|
|
|
|
const wsUrl = `${getWsBaseUrl()}/lsp/${sessionId}?language=${lspLanguage}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
const conn: LSPConnection = {
|
|
ws,
|
|
rpc: null,
|
|
initialized: false,
|
|
refCount: 1,
|
|
idleTimer: null,
|
|
openDocuments: new Map(),
|
|
providerDisposables: [],
|
|
serverCapabilities: null,
|
|
workspacePath: null,
|
|
};
|
|
this.connections.set(key, conn);
|
|
this.setStatus(key, { state: 'connecting' });
|
|
|
|
let bridgeStarted = false;
|
|
|
|
ws.onopen = () => {
|
|
this.setStatus(key, { state: 'starting' });
|
|
};
|
|
|
|
// Listen for backend status messages before the LSP bridge starts.
|
|
const statusHandler = (event: MessageEvent) => {
|
|
if (bridgeStarted) return;
|
|
|
|
let data: { status?: string; error?: string; workspacePath?: string };
|
|
try {
|
|
data = JSON.parse(event.data as string);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (data.status === 'installing') {
|
|
this.setStatus(key, { state: 'installing' });
|
|
} else if (data.status === 'installed') {
|
|
this.setStatus(key, { state: 'starting' });
|
|
} else if (data.status === 'ready') {
|
|
// Language server is running — start the LSP JSON-RPC bridge
|
|
ws.removeEventListener('message', statusHandler);
|
|
bridgeStarted = true;
|
|
this.initializeLsp(key, ws, lspLanguage, data.workspacePath ?? null, userConfigs);
|
|
} else if (data.status === 'install_failed') {
|
|
ws.removeEventListener('message', statusHandler);
|
|
this.setStatus(key, { state: 'error', reason: data.error || 'Install failed' });
|
|
}
|
|
};
|
|
ws.addEventListener('message', statusHandler);
|
|
|
|
ws.onclose = (event) => {
|
|
ws.removeEventListener('message', statusHandler);
|
|
this.disposeConnection(key);
|
|
|
|
const current = this.statuses.get(key);
|
|
if (current?.state === 'ready' || current?.state === 'stopping') {
|
|
this.setStatus(key, { state: 'disabled' });
|
|
this.statuses.delete(key);
|
|
return;
|
|
}
|
|
|
|
if (!bridgeStarted) {
|
|
switch (event.code) {
|
|
case 4001:
|
|
this.setStatus(key, { state: 'unavailable', reason: event.reason || 'Language server not found' });
|
|
break;
|
|
case 4002:
|
|
this.setStatus(key, { state: 'unavailable', reason: 'No active workspace' });
|
|
break;
|
|
case 4003:
|
|
this.setStatus(key, { state: 'error', reason: event.reason || 'Install failed' });
|
|
break;
|
|
default:
|
|
if (current?.state !== 'error' && current?.state !== 'unavailable') {
|
|
this.setStatus(key, { state: 'error', reason: event.reason || 'Connection closed' });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
const current = this.statuses.get(key);
|
|
if (current?.state !== 'error' && current?.state !== 'unavailable') {
|
|
this.setStatus(key, { state: 'error', reason: 'WebSocket error' });
|
|
}
|
|
};
|
|
|
|
return () => this.decrementRef(key);
|
|
}
|
|
|
|
private async initializeLsp(key: string, ws: WebSocket, lspLanguage: string, workspacePath: string | null, userConfigs?: Record<string, Record<string, unknown>>) {
|
|
const conn = this.connections.get(key);
|
|
if (!conn) return;
|
|
|
|
conn.workspacePath = workspacePath;
|
|
|
|
// Merge default configs with user overrides for this language
|
|
const mergedConfig: Record<string, unknown> = {
|
|
...(LSP_DEFAULT_CONFIGS[lspLanguage] ?? {}),
|
|
...(userConfigs?.[lspLanguage] ?? {}),
|
|
};
|
|
|
|
try {
|
|
const rpc = new JsonRpcConnection(ws);
|
|
rpc.listen();
|
|
conn.rpc = rpc;
|
|
|
|
// Handle server requests
|
|
rpc.onRequest('workspace/configuration', (params: unknown) => {
|
|
const items = (params as { items?: { section?: string }[] })?.items;
|
|
if (!Array.isArray(items)) return [mergedConfig];
|
|
return items.map(() => mergedConfig);
|
|
});
|
|
rpc.onRequest('client/registerCapability', () => null);
|
|
rpc.onRequest('window/workDoneProgress/create', () => null);
|
|
|
|
const initResult = await rpc.sendRequest('initialize', {
|
|
processId: null,
|
|
capabilities: {
|
|
textDocument: {
|
|
synchronization: {
|
|
dynamicRegistration: false,
|
|
willSave: false,
|
|
didSave: true,
|
|
willSaveWaitUntil: false,
|
|
},
|
|
completion: {
|
|
dynamicRegistration: false,
|
|
completionItem: {
|
|
snippetSupport: true,
|
|
commitCharactersSupport: true,
|
|
documentationFormat: ['markdown', 'plaintext'],
|
|
deprecatedSupport: true,
|
|
preselectSupport: true,
|
|
},
|
|
contextSupport: true,
|
|
},
|
|
hover: {
|
|
dynamicRegistration: false,
|
|
contentFormat: ['markdown', 'plaintext'],
|
|
},
|
|
definition: { dynamicRegistration: false },
|
|
references: { dynamicRegistration: false },
|
|
signatureHelp: {
|
|
dynamicRegistration: false,
|
|
signatureInformation: {
|
|
documentationFormat: ['markdown', 'plaintext'],
|
|
parameterInformation: { labelOffsetSupport: true },
|
|
},
|
|
},
|
|
publishDiagnostics: { relatedInformation: true },
|
|
semanticTokens: {
|
|
dynamicRegistration: false,
|
|
requests: { full: true },
|
|
tokenTypes: [
|
|
'namespace', 'type', 'class', 'enum', 'interface', 'struct',
|
|
'typeParameter', 'parameter', 'variable', 'property', 'enumMember',
|
|
'event', 'function', 'method', 'macro', 'keyword', 'modifier',
|
|
'comment', 'string', 'number', 'regexp', 'operator', 'decorator',
|
|
],
|
|
tokenModifiers: [
|
|
'declaration', 'definition', 'readonly', 'static', 'deprecated',
|
|
'abstract', 'async', 'modification', 'documentation', 'defaultLibrary',
|
|
],
|
|
formats: ['relative'],
|
|
overlappingTokenSupport: false,
|
|
multilineTokenSupport: false,
|
|
},
|
|
},
|
|
workspace: {
|
|
configuration: true,
|
|
didChangeConfiguration: { dynamicRegistration: false },
|
|
semanticTokens: { refreshSupport: true },
|
|
},
|
|
},
|
|
rootUri: workspacePath ? `file://${workspacePath}` : null,
|
|
workspaceFolders: workspacePath
|
|
? [{ uri: `file://${workspacePath}`, name: workspacePath.split('/').pop() ?? 'workspace' }]
|
|
: null,
|
|
initializationOptions: {},
|
|
}) as { capabilities?: Record<string, unknown> } | null;
|
|
|
|
conn.serverCapabilities = initResult?.capabilities ?? null;
|
|
rpc.sendNotification('initialized', {});
|
|
|
|
// Register diagnostics handler
|
|
rpc.onNotification('textDocument/publishDiagnostics', (params) => {
|
|
this.handleDiagnostics(params as { uri: string; diagnostics: Array<{ range: LspRange; message: string; severity?: number; source?: string; code?: unknown }> });
|
|
});
|
|
|
|
// Suppress Monaco's built-in TS/JS providers BEFORE registering our LSP
|
|
// providers. The suppression flag is checked at registration time in
|
|
// monaco-loader.ts — when it's true, newly registered providers are NOT
|
|
// wrapped with the suppression logic, so our LSP providers always work.
|
|
// The built-in providers (registered earlier while the flag was false)
|
|
// will return empty results while the flag is true.
|
|
if (lspLanguage === 'typescript') {
|
|
setBuiltinTsSuppressed(true);
|
|
}
|
|
|
|
// Collect callbacks for semantic token refresh — the server sends
|
|
// workspace/semanticTokens/refresh when analysis is complete and
|
|
// semantic tokens are ready (e.g. gopls after loading a Go workspace).
|
|
const semanticRefreshCallbacks: (() => void)[] = [];
|
|
rpc.onRequest('workspace/semanticTokens/refresh', () => {
|
|
for (const cb of semanticRefreshCallbacks) cb();
|
|
return null;
|
|
});
|
|
|
|
// Register Monaco providers for this language
|
|
conn.providerDisposables = this.registerProviders(rpc, lspLanguage, key, conn.serverCapabilities, semanticRefreshCallbacks);
|
|
conn.initialized = true;
|
|
|
|
this.setStatus(key, { state: 'ready' });
|
|
} catch (err) {
|
|
console.error(`[LSP] initializeLsp error:`, err);
|
|
this.setStatus(key, { state: 'error', reason: String(err) });
|
|
}
|
|
}
|
|
|
|
// ------- Monaco provider registration -------
|
|
|
|
private registerProviders(rpc: JsonRpcConnection, lspLanguage: string, connectionKey: string, serverCapabilities: Record<string, unknown> | null, semanticRefreshCallbacks: (() => void)[]): IDisposable[] {
|
|
const monaco = getMonacoInstance();
|
|
if (!monaco) return [];
|
|
|
|
const monacoLanguages = getMonacoLanguagesForLsp(lspLanguage);
|
|
const disposables: IDisposable[] = [];
|
|
|
|
for (const lang of monacoLanguages) {
|
|
// Completion
|
|
disposables.push(
|
|
monaco.languages.registerCompletionItemProvider(lang, {
|
|
triggerCharacters: ['.', ':', '<', '"', "'", '/', '@', '#', ' '],
|
|
provideCompletionItems: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, _context: unknown, token: { isCancellationRequested: boolean }) => {
|
|
const uri = this.getDocumentUri(model);
|
|
if (!uri) return { suggestions: [] };
|
|
try {
|
|
const result = await rpc.sendRequest('textDocument/completion', {
|
|
textDocument: { uri },
|
|
position: toLspPosition(position.lineNumber, position.column),
|
|
});
|
|
if (token.isCancellationRequested) return { suggestions: [] };
|
|
const items = Array.isArray(result) ? result : ((result as { items?: unknown[] })?.items ?? []);
|
|
return {
|
|
suggestions: (items as Array<{
|
|
label: string | { label: string; detail?: string; description?: string };
|
|
kind?: number;
|
|
detail?: string;
|
|
documentation?: unknown;
|
|
insertText?: string;
|
|
insertTextFormat?: number;
|
|
textEdit?: { range: LspRange; newText: string };
|
|
additionalTextEdits?: Array<{ range: LspRange; newText: string }>;
|
|
sortText?: string;
|
|
filterText?: string;
|
|
}>).map((item) => {
|
|
const label = typeof item.label === 'string' ? item.label : item.label.label;
|
|
const insertText = item.textEdit?.newText ?? item.insertText ?? label;
|
|
const isSnippet = item.insertTextFormat === 2;
|
|
return {
|
|
label,
|
|
kind: toMonacoCompletionKind(item.kind),
|
|
detail: item.detail,
|
|
documentation: typeof item.documentation === 'string'
|
|
? item.documentation
|
|
: item.documentation && typeof item.documentation === 'object' && 'value' in item.documentation
|
|
? { value: (item.documentation as { value: string }).value }
|
|
: undefined,
|
|
insertText,
|
|
insertTextRules: isSnippet ? 4 /* InsertAsSnippet */ : undefined,
|
|
range: item.textEdit?.range ? toMonacoRange(item.textEdit.range) : undefined,
|
|
sortText: item.sortText,
|
|
filterText: item.filterText,
|
|
additionalTextEdits: item.additionalTextEdits?.map((e) => ({
|
|
range: toMonacoRange(e.range),
|
|
text: e.newText,
|
|
})),
|
|
} as languages.CompletionItem;
|
|
}),
|
|
};
|
|
} catch {
|
|
return { suggestions: [] };
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// Hover
|
|
disposables.push(
|
|
monaco.languages.registerHoverProvider(lang, {
|
|
provideHover: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, token: { isCancellationRequested: boolean }) => {
|
|
const uri = this.getDocumentUri(model);
|
|
if (!uri) return null;
|
|
try {
|
|
const result = await rpc.sendRequest('textDocument/hover', {
|
|
textDocument: { uri },
|
|
position: toLspPosition(position.lineNumber, position.column),
|
|
}) as { contents: unknown; range?: LspRange } | null;
|
|
if (token.isCancellationRequested || !result) return null;
|
|
return {
|
|
range: result.range ? toMonacoRange(result.range) : undefined,
|
|
contents: extractHoverContents(result.contents),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// Go to definition
|
|
disposables.push(
|
|
monaco.languages.registerDefinitionProvider(lang, {
|
|
provideDefinition: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, token: { isCancellationRequested: boolean }) => {
|
|
const uri = this.getDocumentUri(model);
|
|
if (!uri) return null;
|
|
try {
|
|
const result = await rpc.sendRequest('textDocument/definition', {
|
|
textDocument: { uri },
|
|
position: toLspPosition(position.lineNumber, position.column),
|
|
});
|
|
if (token.isCancellationRequested || !result) return null;
|
|
const defs = Array.isArray(result) ? result : [result];
|
|
// Pre-create placeholder models so Monaco's peek/goto doesn't crash
|
|
this.ensureModelsExist(
|
|
defs.map((d: { uri: string }) => d.uri),
|
|
connectionKey,
|
|
);
|
|
return defs.map((d: { uri: string; range: LspRange }) => ({
|
|
uri: monaco.Uri.parse(d.uri),
|
|
range: toMonacoRange(d.range),
|
|
}));
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// References
|
|
disposables.push(
|
|
monaco.languages.registerReferenceProvider(lang, {
|
|
provideReferences: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, context: { includeDeclaration: boolean }, token: { isCancellationRequested: boolean }) => {
|
|
const uri = this.getDocumentUri(model);
|
|
if (!uri) return null;
|
|
try {
|
|
const result = await rpc.sendRequest('textDocument/references', {
|
|
textDocument: { uri },
|
|
position: toLspPosition(position.lineNumber, position.column),
|
|
context: { includeDeclaration: context.includeDeclaration },
|
|
});
|
|
if (token.isCancellationRequested || !result) return null;
|
|
const refs = Array.isArray(result) ? result : [];
|
|
// Pre-create placeholder models so Monaco's peek widget can show previews
|
|
this.ensureModelsExist(
|
|
refs.map((r: { uri: string }) => r.uri),
|
|
connectionKey,
|
|
);
|
|
return refs.map((r: { uri: string; range: LspRange }) => ({
|
|
uri: monaco.Uri.parse(r.uri),
|
|
range: toMonacoRange(r.range),
|
|
}));
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// Signature help
|
|
disposables.push(
|
|
monaco.languages.registerSignatureHelpProvider(lang, {
|
|
signatureHelpTriggerCharacters: ['(', ','],
|
|
provideSignatureHelp: async (model: monacoEditor.ITextModel, position: { lineNumber: number; column: number }, _: { isCancellationRequested: boolean }) => {
|
|
const uri = this.getDocumentUri(model);
|
|
if (!uri) return null;
|
|
try {
|
|
const result = await rpc.sendRequest('textDocument/signatureHelp', {
|
|
textDocument: { uri },
|
|
position: toLspPosition(position.lineNumber, position.column),
|
|
}) as { signatures: Array<{ label: string; documentation?: unknown; parameters?: Array<{ label: string | [number, number]; documentation?: unknown }> }>; activeSignature?: number; activeParameter?: number } | null;
|
|
if (!result || !result.signatures?.length) return null;
|
|
return {
|
|
value: {
|
|
signatures: result.signatures.map((sig) => ({
|
|
label: sig.label,
|
|
documentation: typeof sig.documentation === 'string'
|
|
? sig.documentation
|
|
: sig.documentation && typeof sig.documentation === 'object' && 'value' in sig.documentation
|
|
? { value: (sig.documentation as { value: string }).value }
|
|
: undefined,
|
|
parameters: (sig.parameters ?? []).map((p) => ({
|
|
label: p.label,
|
|
documentation: typeof p.documentation === 'string' ? p.documentation : undefined,
|
|
})),
|
|
})),
|
|
activeSignature: result.activeSignature ?? 0,
|
|
activeParameter: result.activeParameter ?? 0,
|
|
},
|
|
dispose: () => {},
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
})
|
|
);
|
|
|
|
// Semantic tokens — provides rich highlighting (functions, types, variables
|
|
// in different colors) using data from the LSP server.
|
|
const semTokensCap = serverCapabilities?.semanticTokensProvider as
|
|
{ legend?: { tokenTypes: string[]; tokenModifiers: string[] }; full?: boolean | object } | undefined;
|
|
if (semTokensCap?.legend && semTokensCap.full) {
|
|
const legend = semTokensCap.legend;
|
|
|
|
// onDidChange event: fired when the server sends workspace/semanticTokens/refresh
|
|
// (e.g. after gopls finishes analyzing the workspace). This tells Monaco to
|
|
// re-request semantic tokens from the provider.
|
|
const listeners = new Set<() => void>();
|
|
const onDidChange = (listener: () => void) => {
|
|
listeners.add(listener);
|
|
return { dispose: () => listeners.delete(listener) };
|
|
};
|
|
semanticRefreshCallbacks.push(() => {
|
|
for (const l of listeners) l();
|
|
});
|
|
|
|
// Track pending retry timers so they can be disposed
|
|
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
|
|
disposables.push(
|
|
monaco.languages.registerDocumentSemanticTokensProvider(lang, {
|
|
onDidChange,
|
|
getLegend() {
|
|
return legend;
|
|
},
|
|
provideDocumentSemanticTokens: async (model: monacoEditor.ITextModel, lastResultId: string | null, token: { isCancellationRequested: boolean }) => {
|
|
const uri = this.getDocumentUri(model);
|
|
if (!uri) return null;
|
|
try {
|
|
const result = await rpc.sendRequest('textDocument/semanticTokens/full', {
|
|
textDocument: { uri },
|
|
}) as { resultId?: string; data: number[] } | null;
|
|
if (token.isCancellationRequested) return null;
|
|
const dataLen = result?.data?.length ?? 0;
|
|
// If we got 0 tokens (server still loading/analyzing), schedule a retry
|
|
if (dataLen === 0) {
|
|
const timer = setTimeout(() => {
|
|
retryTimers.delete(timer);
|
|
for (const l of listeners) l();
|
|
}, 5000);
|
|
retryTimers.add(timer);
|
|
return null;
|
|
}
|
|
return {
|
|
resultId: result!.resultId,
|
|
data: new Uint32Array(result!.data),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
releaseDocumentSemanticTokens() {},
|
|
})
|
|
);
|
|
// Clean up retry timers when providers are disposed
|
|
disposables.push({ dispose: () => { for (const t of retryTimers) clearTimeout(t); retryTimers.clear(); } });
|
|
}
|
|
}
|
|
|
|
return disposables;
|
|
}
|
|
|
|
// ------- Placeholder models for Go-to-Definition / References -------
|
|
|
|
/**
|
|
* Ensure Monaco models exist for the given file:// URIs so that the peek
|
|
* widget and Go-to-Definition don't throw "Model not found". Models are
|
|
* created with empty content initially, then filled asynchronously from
|
|
* the backend.
|
|
*/
|
|
private ensureModelsExist(uris: string[], connectionKey: string): void {
|
|
const monaco = getMonacoInstance();
|
|
if (!monaco) return;
|
|
|
|
const conn = this.connections.get(connectionKey);
|
|
|
|
for (const fileUri of uris) {
|
|
if (!fileUri.startsWith('file://')) continue;
|
|
const parsed = monaco.Uri.parse(fileUri);
|
|
|
|
// Skip if a model already exists for this URI
|
|
if (monaco.editor.getModel(parsed)) continue;
|
|
|
|
// Create a placeholder model with empty content
|
|
monaco.editor.createModel('', undefined, parsed);
|
|
this.placeholderModels.add(fileUri);
|
|
|
|
// Fetch real content asynchronously and update the model.
|
|
// Only for files inside the workspace — external files (e.g. Go module
|
|
// cache, node_modules in global paths) can't be resolved by the backend's
|
|
// workspace file handler, so we leave placeholder models empty.
|
|
if (conn?.workspacePath) {
|
|
const absolutePath = parsed.path; // e.g. /workspace/src/foo.ts
|
|
if (!absolutePath.startsWith(conn.workspacePath)) continue;
|
|
const relativePath = absolutePath.slice(conn.workspacePath.length + 1);
|
|
|
|
// Extract sessionId from connection key (format: "sessionId:lspLanguage")
|
|
const sessionId = connectionKey.split(':')[0];
|
|
|
|
// Dynamic import to avoid circular dependency
|
|
Promise.all([
|
|
import('@/lib/ws/connection'),
|
|
import('@/lib/ws/workspace-files'),
|
|
]).then(([{ getWebSocketClient }, { requestFileContent }]) => {
|
|
const client = getWebSocketClient();
|
|
if (!client) return;
|
|
return requestFileContent(client, sessionId, relativePath);
|
|
}).then((response) => {
|
|
if (!response) return;
|
|
const model = monaco.editor.getModel(parsed);
|
|
if (model && this.placeholderModels.has(fileUri)) {
|
|
model.setValue(response.content);
|
|
}
|
|
}).catch(() => {
|
|
// Best effort — placeholder stays empty
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Dispose a placeholder model (e.g. when the file is opened in a real tab). */
|
|
disposePlaceholderModel(fileUri: string): void {
|
|
const monaco = getMonacoInstance();
|
|
if (!monaco || !this.placeholderModels.has(fileUri)) return;
|
|
const parsed = monaco.Uri.parse(fileUri);
|
|
const model = monaco.editor.getModel(parsed);
|
|
if (model) model.dispose();
|
|
this.placeholderModels.delete(fileUri);
|
|
}
|
|
|
|
// ------- Document synchronization -------
|
|
|
|
openDocument(sessionId: string, lspLanguage: string, documentUri: string, languageId: string, text: string): void {
|
|
const key = `${sessionId}:${lspLanguage}`;
|
|
const conn = this.connections.get(key);
|
|
if (!conn?.initialized || !conn.rpc) return;
|
|
if (conn.openDocuments.has(documentUri)) return;
|
|
|
|
conn.openDocuments.set(documentUri, { version: 1, languageId });
|
|
conn.rpc.sendNotification('textDocument/didOpen', {
|
|
textDocument: { uri: documentUri, languageId, version: 1, text },
|
|
});
|
|
}
|
|
|
|
changeDocument(sessionId: string, lspLanguage: string, documentUri: string, text: string): void {
|
|
const key = `${sessionId}:${lspLanguage}`;
|
|
const conn = this.connections.get(key);
|
|
if (!conn?.initialized || !conn.rpc) return;
|
|
const doc = conn.openDocuments.get(documentUri);
|
|
if (!doc) return;
|
|
|
|
doc.version++;
|
|
conn.rpc.sendNotification('textDocument/didChange', {
|
|
textDocument: { uri: documentUri, version: doc.version },
|
|
contentChanges: [{ text }],
|
|
});
|
|
}
|
|
|
|
closeDocument(sessionId: string, lspLanguage: string, documentUri: string): void {
|
|
const key = `${sessionId}:${lspLanguage}`;
|
|
const conn = this.connections.get(key);
|
|
if (!conn?.initialized || !conn.rpc) return;
|
|
if (!conn.openDocuments.has(documentUri)) return;
|
|
|
|
conn.openDocuments.delete(documentUri);
|
|
conn.rpc.sendNotification('textDocument/didClose', {
|
|
textDocument: { uri: documentUri },
|
|
});
|
|
}
|
|
|
|
// ------- Helpers -------
|
|
|
|
/** Build a file:// URI from a Monaco model, or null if it can't be determined. */
|
|
private getDocumentUri(model: monacoEditor.ITextModel): string | null {
|
|
const uri = model.uri.toString();
|
|
// If it's already a file:// URI, use it directly
|
|
if (uri.startsWith('file://')) return uri;
|
|
// Otherwise, try to match against open documents by path
|
|
// Monaco @monaco-editor/react creates URIs like "inmemory://model/<path>"
|
|
// or the model's URI path might match the file path we used
|
|
const path = model.uri.path;
|
|
if (path && path.startsWith('/')) return `file://${path}`;
|
|
return null;
|
|
}
|
|
|
|
private handleDiagnostics(params: { uri: string; diagnostics: Array<{ range: LspRange; message: string; severity?: number; source?: string; code?: unknown }> }) {
|
|
const monaco = getMonacoInstance();
|
|
if (!monaco) return;
|
|
|
|
// Find the matching model
|
|
const models = monaco.editor.getModels();
|
|
const targetModel = models.find((m: monacoEditor.ITextModel) => {
|
|
const modelUri = m.uri.toString();
|
|
if (modelUri === params.uri) return true;
|
|
// Match file:// URI to model path
|
|
if (params.uri.startsWith('file://')) {
|
|
const filePath = params.uri.replace('file://', '');
|
|
if (m.uri.path === filePath) return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (!targetModel) return;
|
|
|
|
const markers = params.diagnostics.map((d) => ({
|
|
message: d.message,
|
|
severity: toMonacoSeverity(d.severity),
|
|
...toMonacoRange(d.range),
|
|
source: d.source,
|
|
code: typeof d.code === 'object' && d.code !== null ? String((d.code as { value: unknown }).value) : d.code !== undefined ? String(d.code) : undefined,
|
|
}));
|
|
|
|
monaco.editor.setModelMarkers(targetModel, 'lsp', markers);
|
|
}
|
|
|
|
// ------- Stop / cleanup -------
|
|
|
|
stop(sessionId: string, lspLanguage: string): void {
|
|
const key = `${sessionId}:${lspLanguage}`;
|
|
const conn = this.connections.get(key);
|
|
if (!conn) {
|
|
this.statuses.delete(key);
|
|
this.setStatus(key, { state: 'disabled' });
|
|
return;
|
|
}
|
|
|
|
this.setStatus(key, { state: 'stopping' });
|
|
if (conn.idleTimer) clearTimeout(conn.idleTimer);
|
|
|
|
// Send shutdown/exit before closing
|
|
if (conn.rpc && conn.initialized) {
|
|
try {
|
|
conn.rpc.sendRequest('shutdown', null).then(() => {
|
|
conn.rpc?.sendNotification('exit', null);
|
|
}).catch(() => {});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
this.cleanupConnection(key, conn);
|
|
this.statuses.delete(key);
|
|
for (const listener of this.listeners) {
|
|
listener(key, DISABLED_STATUS);
|
|
}
|
|
}
|
|
|
|
disconnectAll(): void {
|
|
for (const [key, conn] of this.connections) {
|
|
if (conn.idleTimer) clearTimeout(conn.idleTimer);
|
|
this.cleanupConnection(key, conn);
|
|
}
|
|
this.statuses.clear();
|
|
}
|
|
|
|
private decrementRef(key: string) {
|
|
const conn = this.connections.get(key);
|
|
if (!conn) return;
|
|
conn.refCount--;
|
|
if (conn.refCount <= 0) {
|
|
conn.idleTimer = setTimeout(() => {
|
|
this.cleanupConnection(key, conn);
|
|
this.statuses.delete(key);
|
|
for (const listener of this.listeners) {
|
|
listener(key, DISABLED_STATUS);
|
|
}
|
|
}, LSP_IDLE_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
private disposeConnection(key: string) {
|
|
const conn = this.connections.get(key);
|
|
if (!conn) return;
|
|
for (const d of conn.providerDisposables) d.dispose();
|
|
conn.providerDisposables = [];
|
|
conn.rpc?.dispose();
|
|
conn.rpc = null;
|
|
conn.initialized = false;
|
|
conn.openDocuments.clear();
|
|
this.connections.delete(key);
|
|
}
|
|
|
|
private cleanupConnection(key: string, conn: LSPConnection) {
|
|
// Re-enable Monaco's built-in TS/JS providers if this was a TypeScript LSP connection
|
|
const lspLanguage = key.split(':')[1];
|
|
if (lspLanguage === 'typescript') {
|
|
setBuiltinTsSuppressed(false);
|
|
}
|
|
|
|
for (const d of conn.providerDisposables) d.dispose();
|
|
conn.providerDisposables = [];
|
|
conn.rpc?.dispose();
|
|
conn.rpc = null;
|
|
conn.initialized = false;
|
|
conn.openDocuments.clear();
|
|
try {
|
|
if (conn.ws.readyState <= WebSocket.OPEN) {
|
|
conn.ws.close();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.connections.delete(key);
|
|
|
|
// Dispose placeholder models created for this connection
|
|
const monaco = getMonacoInstance();
|
|
if (monaco) {
|
|
for (const uri of this.placeholderModels) {
|
|
const parsed = monaco.Uri.parse(uri);
|
|
const model = monaco.editor.getModel(parsed);
|
|
if (model) model.dispose();
|
|
}
|
|
this.placeholderModels.clear();
|
|
|
|
// Clear any LSP markers from Monaco models
|
|
for (const model of monaco.editor.getModels()) {
|
|
monaco.editor.setModelMarkers(model, 'lsp', []);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Language mapping helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getMonacoLanguagesForLsp(lspLanguage: string): string[] {
|
|
switch (lspLanguage) {
|
|
case 'typescript': return ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'];
|
|
case 'go': return ['go'];
|
|
case 'rust': return ['rust'];
|
|
case 'python': return ['python'];
|
|
default: return [];
|
|
}
|
|
}
|
|
|
|
export function toLspLanguage(monacoLanguage: string): string | null {
|
|
const map: Record<string, string> = {
|
|
typescript: 'typescript',
|
|
javascript: 'typescript',
|
|
typescriptreact: 'typescript',
|
|
javascriptreact: 'typescript',
|
|
go: 'go',
|
|
rust: 'rust',
|
|
python: 'python',
|
|
};
|
|
return map[monacoLanguage] ?? null;
|
|
}
|
|
|
|
export const lspClientManager = new LSPClientManager();
|