diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 59eae1966..1f5990b26 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -9,7 +9,7 @@ import { serverCompletion, serverDiagnostics, } from "@codemirror/lsp-client"; -import { EditorState, Extension, Facet, MapMode } from "@codemirror/state"; +import { EditorState, Extension, Facet } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import lspStatusBar from "components/lspStatusBar"; import notificationManager from "lib/notificationManager"; @@ -52,6 +52,9 @@ import type { Transport, } from "./types"; import AcodeWorkspace from "./workspace"; +import { applyTextEdits } from "./textEditUtils"; + +const LSP_IDLE_GRACE_MS = 45_000; // grace period before a client with no open files is actually disposed export const lspCompletionEnabled = Facet.define({ // File-level marker used by the autocomplete override path. If any attached @@ -183,9 +186,14 @@ function connectClient( initializationOptions?: Record, rootUri?: string | null, ): void { - const hasInitializationOptions = - !!initializationOptions && Object.keys(initializationOptions).length > 0; - if (!hasInitializationOptions && !rootUri) { + const workspaceFolders = rootUri + ? [{ uri: rootUri, name: deriveFolderName(rootUri) }] + : undefined; + + if ( + (!initializationOptions || !Object.keys(initializationOptions).length) && + !workspaceFolders + ) { client.connect(transport); return; } @@ -205,14 +213,8 @@ function connectClient( if (method === "initialize" && isPlainObject(params)) { params = { ...params, - ...(hasInitializationOptions ? { initializationOptions } : {}), - ...(rootUri - ? { - workspaceFolders: [ - { uri: rootUri, name: workspaceName(rootUri) }, - ], - } - : {}), + ...(initializationOptions ? { initializationOptions } : {}), + ...(workspaceFolders ? { workspaceFolders } : {}), } as Params; } return originalRequestInner(method, params, mapped); @@ -225,13 +227,14 @@ function connectClient( } } -function workspaceName(rootUri: string): string { - const trimmed = rootUri.replace(/\/+$/, ""); - const encodedName = trimmed.slice(trimmed.lastIndexOf("/") + 1); +function deriveFolderName(uri: string): string { try { - return decodeURIComponent(encodedName) || "workspace"; + const decoded = decodeURIComponent(uri); + const trimmed = decoded.replace(/\/+$/, ""); + const segments = trimmed.split("/").filter(Boolean); + return segments[segments.length - 1] || decoded; } catch { - return encodedName || "workspace"; + return uri; } } @@ -797,8 +800,17 @@ export class LspClientManager { }, workspace: { configuration: true, + applyEdit: true, workspaceFolders: true, }, + textDocument: { + codeAction: { + dataSupport: true, + resolveSupport: { + properties: ["edit"], + }, + }, + }, }, }; @@ -1045,14 +1057,20 @@ export class LspClientManager { client = new LSPClient(clientConfig) as ExtendedLSPClient; client.__acodeServerId = server.id; connectClient( - client, - transportHandle.transport, - initializationOptions, - scope === "workspace" && server.useWorkspaceFolders - ? null - : normalizedRootUri, - ); + client, + transportHandle.transport, + initializationOptions, + normalizedRootUri, +); await waitForInitialization(client.initializing, signal, server.id); + // Fire after "initialized" + // it reuses initializationOptions as the config payload + // For LSPs sometimes requiring config to be sent twice like pylsp) + transportHandle.transport.send(JSON.stringify({ + jsonrpc: "2.0", + method: "workspace/didChangeConfiguration", + params: { settings: server.initializationOptions ?? {} }, + })); if (!client.__acodeLoggedInfo) { // Log root URI info to console if (normalizedRootUri) { @@ -1135,23 +1153,27 @@ export class LspClientManager { const uriAliases = new Map(); const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; let disposed = false; - + let idleTimer: ReturnType | undefined; const attach = ( - uri: string, - view: EditorView, - aliases: string[] = [], - ): void => { - const existing = fileRefs.get(uri) ?? new Set(); - existing.add(view); - fileRefs.set(uri, existing); - uriAliases.set(uri, uri); - for (const alias of aliases) { - if (!alias || alias === uri) continue; - uriAliases.set(alias, uri); - } - const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; - logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); - }; + uri: string, + view: EditorView, + aliases: string[] = [], +): void => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + const existing = fileRefs.get(uri) ?? new Set(); + existing.add(view); + fileRefs.set(uri, existing); + uriAliases.set(uri, uri); + for (const alias of aliases) { + if (!alias || alias === uri) continue; + uriAliases.set(alias, uri); + } + const suffix = effectiveRoot ? ` (root ${effectiveRoot})` : ""; + logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); +}; const clearClientDiagnostics = (view: EditorView): void => { try { @@ -1164,6 +1186,10 @@ export class LspClientManager { const dispose = async (): Promise => { if (disposed) return; disposed = true; + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } disposePullDiagnostics(client); this.#clients.delete(key); for (const views of fileRefs.values()) { @@ -1206,13 +1232,18 @@ export class LspClientManager { } if (!fileRefs.size) { - this.options.onClientIdle?.({ - server, - client, - rootUri: effectiveRoot, - dispose, - }); - } + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimer = undefined; + if (fileRefs.size) return; // a file reattached during the grace window + this.options.onClientIdle?.({ + server, + client, + rootUri: effectiveRoot, + dispose, + }); + }, LSP_IDLE_GRACE_MS); +} }; return { @@ -1404,45 +1435,6 @@ interface Change { insert: string; } -function applyTextEdits( - plugin: LSPPlugin, - view: EditorView, - edits: TextEdit[], -): boolean { - const changes: Change[] = []; - for (const edit of edits) { - if (!edit?.range) continue; - let fromBase: number; - let toBase: number; - try { - fromBase = plugin.fromPosition(edit.range.start, plugin.syncedDoc); - toBase = plugin.fromPosition(edit.range.end, plugin.syncedDoc); - } catch (_) { - continue; - } - const fromResult = plugin.unsyncedChanges.mapPos( - fromBase, - 1, - MapMode.TrackDel, - ); - const toResult = plugin.unsyncedChanges.mapPos( - toBase, - -1, - MapMode.TrackDel, - ); - if (fromResult == null || toResult == null) continue; - const insert = - typeof edit.newText === "string" - ? edit.newText.replace(/\r\n/g, "\n") - : ""; - changes.push({ from: fromResult, to: toResult, insert }); - } - if (!changes.length) return false; - changes.sort((a, b) => a.from - b.from || a.to - b.to); - view.dispatch({ changes }); - return true; -} - function buildFormattingOptions( view: EditorView, overrides: FormattingOptions = {}, @@ -1482,6 +1474,7 @@ function resolveIndentWidth(unit: string): number { return width || 4; } + const defaultManager = new LspClientManager(); export default defaultManager; @@ -1500,13 +1493,13 @@ function normalizeRootUriForServer( if (scheme === "file") { return { normalizedRootUri: rootUri, originalRootUri: rootUri }; } - // Try to convert content:// URIs to file:// URIs if (scheme === "content") { const fileUri = contentUriToFileUri(rootUri); if (fileUri) { return { normalizedRootUri: fileUri, originalRootUri: rootUri }; } + // Can't convert to file:// - server won't work properly return { normalizedRootUri: null, originalRootUri: rootUri }; } @@ -1525,6 +1518,12 @@ function normalizeDocumentUri(uri: string | null | undefined): string | null { if (scheme === "file" || scheme === "untitled") { return uri; } + + // sftp documents: strip to the bare remote path + if (scheme === "sftp") { + return sftpUriToFileUri(uri); + } + // Convert content:// URIs to file:// URIs if (scheme === "content") { @@ -1611,6 +1610,16 @@ function contentUriToFileUri(uri: string): string | null { } } +function sftpUriToFileUri(uri: string): string | null { + // reached via this SFTP connection, so the server needs only the bare + // remote path — no scheme, host, port, or credentials. + const match = /^sftp:\/\/[^/]*(\/.*)$/.exec(uri); + if (!match) return null; + const path = match[1].split("?")[0]; + if (!path) return null; + return buildFileUri(path); +} + function buildFileUri(pathname: string): string | null { if (!pathname) return null; const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`; diff --git a/src/cm/lsp/textEditUtils.ts b/src/cm/lsp/textEditUtils.ts new file mode 100644 index 000000000..76576fba9 --- /dev/null +++ b/src/cm/lsp/textEditUtils.ts @@ -0,0 +1,72 @@ +import type { LSPPlugin } from "@codemirror/lsp-client"; +import type { EditorView } from "@codemirror/view"; +import { MapMode } from "@codemirror/state"; +import type { Text } from "@codemirror/state"; +import type { TextEdit } from "vscode-languageserver-types"; + +interface Change { + from: number; + to: number; + insert: string; +} + +/** + * Convert an LSP Position to a CodeMirror document offset, clamping to + * document bounds. Handles the LSP convention where line == doc.lines + * means "end of document" (e.g. full-document formatting replacements). + */ +export function lspPositionToOffset( + doc: Text, + pos: { line: number; character: number }, +): number { + if (pos.line < 0) return 0; + if (pos.line >= doc.lines) return doc.length; + const line = doc.line(pos.line + 1); + return line.from + Math.min(pos.character, line.length); +} + +/** + * Apply a list of LSP TextEdits to an EditorView backed by the given + * LSPPlugin. Shared by clientManager.ts (edits the client pulls, e.g. + * textDocument/formatting) and transport.ts (edits the server pushes, + * e.g. workspace/applyEdit). + */ +export function applyTextEdits( + plugin: LSPPlugin, + view: EditorView, + edits: TextEdit[], +): boolean { + const changes: Change[] = []; + for (const edit of edits) { + if (!edit?.range) continue; + let fromBase: number; + let toBase: number; + try { + fromBase = lspPositionToOffset(plugin.syncedDoc, edit.range.start); + toBase = lspPositionToOffset(plugin.syncedDoc, edit.range.end); + } catch (err) { + console.error("[applyTextEdits] position conversion failed:", err, edit); + continue; + } + const fromResult = plugin.unsyncedChanges.mapPos( + fromBase, + 1, + MapMode.TrackDel, + ); + const toResult = plugin.unsyncedChanges.mapPos( + toBase, + -1, + MapMode.TrackDel, + ); + if (fromResult == null || toResult == null) continue; + const insert = + typeof edit.newText === "string" + ? edit.newText.replace(/\r\n/g, "\n") + : ""; + changes.push({ from: fromResult, to: toResult, insert }); + } + if (!changes.length) return false; + changes.sort((a, b) => a.from - b.from || a.to - b.to); + view.dispatch({ changes }); + return true; +} \ No newline at end of file diff --git a/src/cm/lsp/transport.ts b/src/cm/lsp/transport.ts index 1095687e6..6a59e01eb 100644 --- a/src/cm/lsp/transport.ts +++ b/src/cm/lsp/transport.ts @@ -11,6 +11,10 @@ import type { TransportHandle, WebSocketTransportOptions, } from "./types"; +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { TextEdit } from "vscode-languageserver-types"; +import { applyTextEdits } from "./textEditUtils"; +import type AcodeWorkspace from "./workspace"; const DEFAULT_TIMEOUT = 5000; const RECONNECT_BASE_DELAY = 500; @@ -157,76 +161,187 @@ function createWebSocketTransport( dispatchToListeners(data); } - function dispatchToListeners(data: string): void { - // Debugging aid while stabilising websocket transport - if (context?.debugWebSocket) { - console.debug(`[LSP:${server.id}] <=`, data); +interface WorkspaceEditParam { + changes?: Record; + documentChanges?: Array<{ textDocument: { uri: string }; edits: TextEdit[] }>; +} + +async function applyWorkspaceEditToContext( + edit: WorkspaceEditParam | undefined, + ctx: TransportContext, +): Promise<{ applied: boolean; failureReason?: string }> { + if (!edit) return { applied: false, failureReason: "No edit provided" }; + + const changesByUri: Record = + edit.changes ?? + Object.fromEntries( + (edit.documentChanges ?? []) + .filter((c): c is { textDocument: { uri: string }; edits: TextEdit[] } => "edits" in c) + .map((c) => [c.textDocument.uri, c.edits]), + ); + + const uris = Object.keys(changesByUri); + if (!uris.length) { + return { applied: false, failureReason: "Edit contains no changes" }; + } + + const workspace = ctx.view + ? (LSPPlugin.get(ctx.view)?.client.workspace as AcodeWorkspace | undefined) + : undefined; + + if (!workspace) { + return { applied: false, failureReason: "No workspace available to apply edit" }; + } + + let appliedCount = 0; + const failures: string[] = []; + + for (const uri of uris) { + const edits = changesByUri[uri]; + if (!edits.length) continue; + + let view = workspace.getFile(uri)?.getView(); + if (!view) { + try { + view = await workspace.displayFile(uri); + } catch (error) { + failures.push(uri); + continue; + } + } + if (!view) { + failures.push(uri); + continue; } - try { - const msg = JSON.parse(data); - if (msg && typeof msg.id !== "undefined") { - let handled = true; - let result: unknown = null; - switch (msg.method) { - case "window/workDoneProgress/create": - case "workspace/diagnostic/refresh": - case "client/registerCapability": - case "client/unregisterCapability": - break; - case "workspace/configuration": - result = Array.isArray(msg.params?.items) - ? msg.params.items.map( - (item: { section?: unknown }) => - resolveWorkspaceConfiguration(item?.section), - ) - : []; - break; - case "workspace/workspaceFolders": { - const rootUri = context.rootUri; - result = rootUri - ? [ - { - uri: rootUri, - name: - rootUri.replace(/\/$/, "").split("/").pop() || - rootUri, - }, - ] - : null; - break; - } - default: - handled = false; - } - if (!handled) { - notifyListeners(data); - return; - } - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result, - }); - if (context?.debugWebSocket) { - console.debug(`[LSP:${server.id}] => (auto-response)`, response); - } - sendMessage(response); - if (msg.method === "workspace/diagnostic/refresh") { - notifyListeners( - JSON.stringify({ + const plugin = LSPPlugin.get(view); + if (!plugin) { + failures.push(uri); + continue; + } + + const applied = applyTextEdits(plugin, view, edits); + if (applied) appliedCount++; + else failures.push(uri); + } + + if (appliedCount === 0) { + return { + applied: false, + failureReason: `Could not apply edit to: ${failures.join(", ")}`, + }; + } + if (failures.length) { + return { + applied: false, + failureReason: `Applied to ${appliedCount} file(s); failed: ${failures.join(", ")}`, + }; + } + return { applied: true }; +} + + function dispatchToListeners(data: string): void { + // Debugging aid while stabilising websocket transport + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] <=`, data); + } + + try { + const msg = JSON.parse(data); + if (msg && typeof msg.id !== "undefined") { + // workspace/applyEdit needs to await file-opening/edit-application, + // so it can't go through the synchronous switch below. Handle it + // separately and return immediately. + if (msg.method === "workspace/applyEdit") { + applyWorkspaceEditToContext(msg.params?.edit, context) + .then((result) => { + const response = JSON.stringify({ jsonrpc: "2.0", - method: msg.method, - params: msg.params ?? {}, - }), - ); + id: msg.id, + result, + }); + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] => (auto-response)`, response); + } + sendMessage(response); + }) + .catch((error) => { + console.error(`[LSP:${server.id}] workspace/applyEdit failed:`, error); + sendMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { + applied: false, + failureReason: "Internal error applying edit", + }, + }), + ); + }); + return; + } + + let handled = true; + let result: unknown = null; + switch (msg.method) { + case "window/workDoneProgress/create": + case "workspace/diagnostic/refresh": + case "client/registerCapability": + case "client/unregisterCapability": + break; + case "workspace/configuration": + result = Array.isArray(msg.params?.items) + ? msg.params.items.map( + (item: { section?: unknown }) => + resolveWorkspaceConfiguration(item?.section), + ) + : []; + break; + case "workspace/workspaceFolders": { + const rootUri = context.rootUri; + result = rootUri + ? [ + { + uri: rootUri, + name: + rootUri.replace(/\/$/, "").split("/").pop() || + rootUri, + }, + ] + : null; + break; } + default: + handled = false; + } + if (!handled) { + notifyListeners(data); return; } - } catch (_) {} + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result, + }); + if (context?.debugWebSocket) { + console.debug(`[LSP:${server.id}] => (auto-response)`, response); + } + sendMessage(response); + if (msg.method === "workspace/diagnostic/refresh") { + notifyListeners( + JSON.stringify({ + jsonrpc: "2.0", + method: msg.method, + params: msg.params ?? {}, + }), + ); + } + return; + } + } catch (_) {} - notifyListeners(data); - } + notifyListeners(data); +} function handleClose(event: CloseEvent): void { connected = false;