From 92dfadcc3291552de46a32f8aaf51d68c9ecbdd4 Mon Sep 17 00:00:00 2001 From: gat0sy Date: Fri, 7 Aug 2026 16:07:14 +0000 Subject: [PATCH 1/3] feat(lsp): fixing LspToPosition adding a helper textEditUtils LspToPosition threw range error on format error. We attempt to fix it here with by clamping so we get the correct line count between the client and server. applyTextEdit as also been extracted so both transport and client manager can import it from the helper. --- src/cm/lsp/clientManager.ts | 185 ++++++++++++----------- src/cm/lsp/textEditUtils.ts | 72 +++++++++ src/cm/lsp/transport.ts | 291 ++++++++++++++++++++++++++++-------- 3 files changed, 397 insertions(+), 151 deletions(-) create mode 100644 src/cm/lsp/textEditUtils.ts diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 59eae1966..f66cd2a02 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; } } @@ -656,7 +659,7 @@ export class LspClientManager { const key = scope === "document" ? `${runtimeServerKey}::__document__::${documentUri}` - : pluginKey(runtimeServerKey, normalizedRootUri, useWsFolders); + : pluginKey(runtimeServerKey, originalRootUri ?? normalizedRootUri, useWsFolders); // Return existing client if already initialized if (this.#clients.has(key)) { @@ -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..c43e14833 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,237 @@ 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[] } + | { kind: "create" | "rename" | "delete"; uri: string + }>; +} + +async function applyWorkspaceEditToContext( + edit: WorkspaceEditParam | undefined, + ctx: TransportContext, +): Promise<{ applied: boolean; failureReason?: string }> { + if (!edit) return { applied: false, failureReason: "No edit provided" }; + + // Reject resource operations explicitly + const resourceOps = (edit.documentChanges ?? []).filter( + (c): c is { + kind: "create" | "rename" | "delete"; uri: string + } => "kind" in c,); + + if (resourceOps.length > 0) { + return { + applied: false, + failureReason: `Resource operations not supported: ${resourceOps.map((o) => o.kind).join(", ")}`, + }; + } + + // Accumulate edits per URI (don't overwrite duplicates) + const changesByUri: Record < string, + TextEdit[] > = {}; + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + changesByUri[uri] = [...(changesByUri[uri] ?? []), + ...edits]; + } + } + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if ("edits" in change) { + const uri = change.textDocument.uri; + changesByUri[uri] = [...(changesByUri[uri] ?? []), + ...change.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" }; + } + // Workspace boundary check + const allowedRoots = [ctx.rootUri, + ctx.originalRootUri].filter( + (r): r is string => !!r,); + + let appliedCount = 0; + const failures: string[] = []; + + for (const uri of uris) { + const edits = changesByUri[uri]; + if (!edits.length) continue; + + // Security: reject edits outside workspace roots + const inWorkspace = + !allowedRoots.length || + allowedRoots.some( + (root) => uri === root || uri.startsWith(`${root}/`), + ); + if (!inWorkspace) { + failures.push(uri); + 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; + } + + // Find the plugin belonging to THIS server, not just any plugin + const allPlugins = LSPPlugin.getAll(view); + const plugin = + allPlugins.find( + (p) => (p.client as { + __acodeServerId?: string + }).__acodeServerId === server.id, + ) ?? allPlugins[0]; + + if (!plugin) { + 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 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; From 5d65348e6911d83831517e41eb759b57d32bb143 Mon Sep 17 00:00:00 2001 From: gat0sy Date: Fri, 7 Aug 2026 16:07:14 +0000 Subject: [PATCH 2/3] feat(lsp): fixing LspToPosition adding a helper textEditUtils LspToPosition threw range error on format error. We attempt to fix it here with by clamping so we get the correct line count between the client and server. applyTextEdit as also been extracted so both transport and client manager can import it from the helper. --- src/cm/lsp/transport.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/cm/lsp/transport.ts b/src/cm/lsp/transport.ts index c43e14833..5bb84f9e1 100644 --- a/src/cm/lsp/transport.ts +++ b/src/cm/lsp/transport.ts @@ -167,6 +167,7 @@ interface WorkspaceEditParam { | { textDocument: { uri: string }; edits: TextEdit[] } | { kind: "create" | "rename" | "delete"; uri: string }>; + documentChanges?: Array<{ textDocument: { uri: string }; edits: TextEdit[] }>; } async function applyWorkspaceEditToContext( @@ -207,6 +208,16 @@ async function applyWorkspaceEditToContext( } } +======= + 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]), + ); + +>>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) const uris = Object.keys(changesByUri); if (!uris.length) { return { applied: false, failureReason: "Edit contains no changes" }; @@ -219,10 +230,13 @@ async function applyWorkspaceEditToContext( if (!workspace) { return { applied: false, failureReason: "No workspace available to apply edit" }; } +<<<<<<< HEAD // Workspace boundary check const allowedRoots = [ctx.rootUri, ctx.originalRootUri].filter( (r): r is string => !!r,); +======= +>>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) let appliedCount = 0; const failures: string[] = []; @@ -230,6 +244,7 @@ async function applyWorkspaceEditToContext( for (const uri of uris) { const edits = changesByUri[uri]; if (!edits.length) continue; +<<<<<<< HEAD // Security: reject edits outside workspace roots const inWorkspace = @@ -241,6 +256,8 @@ async function applyWorkspaceEditToContext( failures.push(uri); continue; } +======= +>>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) let view = workspace.getFile(uri)?.getView(); if (!view) { @@ -254,6 +271,7 @@ async function applyWorkspaceEditToContext( if (!view) { failures.push(uri); continue; +<<<<<<< HEAD } // Find the plugin belonging to THIS server, not just any plugin @@ -270,6 +288,16 @@ async function applyWorkspaceEditToContext( continue; } +======= + } + + const plugin = LSPPlugin.get(view); + if (!plugin) { + failures.push(uri); + continue; + } + +>>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) const applied = applyTextEdits(plugin, view, edits); if (applied) appliedCount++; else failures.push(uri); From 8a7ce5bcae14b7bc67bfff4edc2f11dd8d7698c0 Mon Sep 17 00:00:00 2001 From: gat0sy Date: Fri, 7 Aug 2026 22:57:17 +0000 Subject: [PATCH 3/3] feat(lsp): add go-to-definition, code action fixes, and hover file links go to def and similar fonction have been added, a new interceptFileLink method has been created to solve an FileUriExposedException you may get if taping the signature link on the hover. if the link is a website, it skips and let the normal behavior occur ( open a web browser page ) if the link is a file, it modifies the uri so the tap behave like a go to instead of crashing the whole app. There are notably also some fixes for code actions, rename...ect, now they use the new lspPostionToOffset that uses clamping --- src/cm/lsp/codeActions.ts | 8 +- src/cm/lsp/definition.ts | 145 ++++++++++++++++++++++++++++++++ src/cm/lsp/index.ts | 6 ++ src/cm/lsp/references.ts | 4 +- src/cm/lsp/rename.ts | 9 +- src/cm/lsp/tooltipExtensions.ts | 58 ++++++++++++- src/cm/lsp/transport.ts | 28 ------ 7 files changed, 212 insertions(+), 46 deletions(-) create mode 100644 src/cm/lsp/definition.ts diff --git a/src/cm/lsp/codeActions.ts b/src/cm/lsp/codeActions.ts index 7022e5f1e..1f8ea915b 100644 --- a/src/cm/lsp/codeActions.ts +++ b/src/cm/lsp/codeActions.ts @@ -14,6 +14,7 @@ import type { import type { Position, Range } from "./types"; import { addLspLogFor } from "./logs"; import type AcodeWorkspace from "./workspace"; +import { lspPositionToOffset } from "./textEditUtils"; type CodeActionResponse = (CodeAction | Command)[] | null; @@ -61,13 +62,6 @@ function isCommand(item: CodeAction | Command): item is Command { ); } -function lspPositionToOffset( - doc: { line: (n: number) => { from: number } }, - pos: Position, -): number { - return doc.line(pos.line + 1).from + pos.character; -} - async function requestCodeActions( plugin: LSPPlugin, range: LspRange, diff --git a/src/cm/lsp/definition.ts b/src/cm/lsp/definition.ts new file mode 100644 index 000000000..3bf75aad5 --- /dev/null +++ b/src/cm/lsp/definition.ts @@ -0,0 +1,145 @@ +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { EditorView } from "@codemirror/view"; +import { showReferencesPanel } from "components/referencesPanel"; +import { fetchLineText, getWordAtCursor } from "./references"; +import toast from "components/toast"; + +interface Position { + line: number; + character: number; +} + +interface Range { + start: Position; + end: Position; +} + +interface Location { + uri: string; + range: Range; +} + +interface LocationLink { + targetUri: string; + targetRange: Range; + targetSelectionRange?: Range; +} + +type DefinitionResult = Location | Location[] | LocationLink[] | null; + +interface ReferenceWithContext extends Location { + lineText?: string; +} + +type DefinitionKind = + | "definition" + | "declaration" + | "implementation" + | "typeDefinition"; + +const CAPABILITY_KEY: Record = { + definition: "definitionProvider", + declaration: "declarationProvider", + implementation: "implementationProvider", + typeDefinition: "typeDefinitionProvider", +}; + +const LABEL: Record = { + definition: "definition", + declaration: "declaration", + implementation: "implementation", + typeDefinition: "type definition", +}; + +function normalizeLocations(result: DefinitionResult): Location[] { + if (!result) return []; + const list = Array.isArray(result) ? result : [result]; + return list.map((item) => { + if ("targetUri" in item) { + return { + uri: item.targetUri, + range: item.targetSelectionRange ?? item.targetRange, + }; + } + return item; + }); +} + +async function fetchLocations( + view: EditorView, + kind: DefinitionKind, +): Promise { + const plugins = LSPPlugin.getAll(view); + const plugin = plugins.find((p) => { + const caps = p.client.serverCapabilities as Record | undefined; + return !!caps?.[CAPABILITY_KEY[kind]]; +}) ?? plugins[0]; + if (!plugin) return null; + const client = plugin.client; + const capabilities = plugin.client.serverCapabilities as Record | undefined; + if (!capabilities?.[CAPABILITY_KEY[kind]]) { + toast(`Language server does not support go to ${LABEL[kind]}`); + return null; + } + + const { state } = view; + const pos = state.selection.main.head; + const line = state.doc.lineAt(pos); + const uri = plugin.uri; + + client.sync(); + + const method = `textDocument/${kind}`; + const params = { + textDocument: { uri }, + position: { line: line.number - 1, character: pos - line.from }, + }; + + const result = await client.request( + method, + params, + ); + + return normalizeLocations(result); +} + +async function goTo(view: EditorView, kind: DefinitionKind): Promise { + try { + const locations = await fetchLocations(view, kind); + if (locations === null) return false; + + if (locations.length === 0) { + toast(`No ${LABEL[kind]} found`); + return false; + } + + if (locations.length === 1) { + const { navigateToReference } = await import( + "components/referencesPanel/utils" + ); + await navigateToReference(locations[0]); + return true; + } + + const symbolName = getWordAtCursor(view); + const panel = showReferencesPanel({ symbolName }); + const withContext: ReferenceWithContext[] = await Promise.all( + locations.map(async (loc) => ({ + ...loc, + lineText: await fetchLineText(loc.uri, loc.range.start.line), + })), + ); + panel.setReferences(withContext); + return true; + } catch (error) { + console.error(`Go to ${LABEL[kind]} failed:`, error); + return false; + } +} + +export const goToDefinition = (view: EditorView) => goTo(view, "definition"); +export const goToDeclaration = (view: EditorView) => goTo(view, "declaration"); +export const goToImplementation = (view: EditorView) => + goTo(view, "implementation"); +export const goToTypeDefinition = (view: EditorView) => + goTo(view, "typeDefinition"); \ No newline at end of file diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index f3a8b4d36..3676518b8 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -89,6 +89,12 @@ export { findAllReferences, findAllReferencesInTab, } from "./references"; +export { + goToDefinition, + goToDeclaration, + goToImplementation, + goToTypeDefinition, +} from "./definition"; export { acodeRenameExtension, acodeRenameKeymap, diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index def69288c..cb02fe207 100644 --- a/src/cm/lsp/references.ts +++ b/src/cm/lsp/references.ts @@ -33,7 +33,7 @@ interface ReferenceParams { context: { includeDeclaration: boolean }; } -async function fetchLineText(uri: string, line: number): Promise { +export async function fetchLineText(uri: string, line: number): Promise { try { interface EditorManagerLike { getFile?: (uri: string, type: string) => EditorFileLike | null; @@ -89,7 +89,7 @@ async function fetchLineText(uri: string, line: number): Promise { return ""; } -function getWordAtCursor(view: EditorView): string { +export function getWordAtCursor(view: EditorView): string { const { state } = view; const pos = state.selection.main.head; const word = state.wordAt(pos); diff --git a/src/cm/lsp/rename.ts b/src/cm/lsp/rename.ts index 75b89693c..fd5855a83 100644 --- a/src/cm/lsp/rename.ts +++ b/src/cm/lsp/rename.ts @@ -9,6 +9,7 @@ import prompt from "dialogs/prompt"; import type * as lsp from "vscode-languageserver-protocol"; import { addLspLogFor } from "./logs"; import type AcodeWorkspace from "./workspace"; +import { lspPositionToOffset } from "./textEditUtils"; interface RenameParams { newName: string; @@ -148,14 +149,6 @@ async function performRename(view: EditorView): Promise { return true; } -function lspPositionToOffset( - doc: { line: (n: number) => { from: number } }, - pos: lsp.Position, -): number { - const line = doc.line(pos.line + 1); - return line.from + pos.character; -} - async function applyChangesToFile( workspace: AcodeWorkspace, uri: string, diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index 2a9a157ea..34638f10d 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -40,6 +40,7 @@ import type { MarkupContent, } from "vscode-languageserver-types"; import { getMode, getModeForPath, type Mode } from "../modelist"; +import type AcodeWorkspace from "./workspace"; interface LspClientInternals { config?: { @@ -159,6 +160,60 @@ function startPluginLanguageLoad(mode: Mode): Promise | null { return load; } +function interceptFileLinks(container: HTMLElement, view: EditorView): void { + container.addEventListener("click", + (event) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null; + if (!anchor) return; + + const href = anchor.getAttribute("href"); + if (!href || !href.startsWith("file://")) return; + + event.preventDefault(); + event.stopPropagation(); + + const plugin = LSPPlugin.get(view); + if (!plugin) return; + const workspace = plugin.client.workspace as AcodeWorkspace; + if (!workspace) return; + + void (async () => { + try { + const match = /^(file:\/\/[^#]*)(?:#L?(\d+))?/.exec(href); + if (!match) return; + const [, + rawUri, + lineStr] = match; + + const targetView = await workspace.displayFile(rawUri); + if (!targetView) return; + + if (lineStr) { + const line = Number.parseInt(lineStr, 10); + const doc = targetView.state.doc; + if (Number.isFinite(line) && line >= 1 && line <= doc.lines) { + const { + from + } = doc.line(line); + targetView.dispatch({ + selection: { + anchor: from + }, + effects: EditorView.scrollIntoView(from, { + y: "center" + }), + }); + targetView.focus(); + } + } + } catch (error) { + console.error("[LSP:Tooltip] Failed to open file link:", href, error); + } + })(); + }); +} + export function resolveLspHoverHighlightLanguage( language: string, ): Language | null { @@ -419,6 +474,7 @@ function lspTooltipSource( results[index].result.contents, ); } + interceptFileLinks(dom, view); return { dom }; }, above: true, @@ -620,9 +676,9 @@ function drawSignatureTooltip( const docs = dom.appendChild(document.createElement("div")); docs.className = "cm-lsp-signature-documentation cm-lsp-documentation"; docs.innerHTML = plugin.docToHTML(signature.documentation); + interceptFileLinks(docs, view); } } - return { dom }; } diff --git a/src/cm/lsp/transport.ts b/src/cm/lsp/transport.ts index 5bb84f9e1..c43e14833 100644 --- a/src/cm/lsp/transport.ts +++ b/src/cm/lsp/transport.ts @@ -167,7 +167,6 @@ interface WorkspaceEditParam { | { textDocument: { uri: string }; edits: TextEdit[] } | { kind: "create" | "rename" | "delete"; uri: string }>; - documentChanges?: Array<{ textDocument: { uri: string }; edits: TextEdit[] }>; } async function applyWorkspaceEditToContext( @@ -208,16 +207,6 @@ async function applyWorkspaceEditToContext( } } -======= - 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]), - ); - ->>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) const uris = Object.keys(changesByUri); if (!uris.length) { return { applied: false, failureReason: "Edit contains no changes" }; @@ -230,13 +219,10 @@ async function applyWorkspaceEditToContext( if (!workspace) { return { applied: false, failureReason: "No workspace available to apply edit" }; } -<<<<<<< HEAD // Workspace boundary check const allowedRoots = [ctx.rootUri, ctx.originalRootUri].filter( (r): r is string => !!r,); -======= ->>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) let appliedCount = 0; const failures: string[] = []; @@ -244,7 +230,6 @@ async function applyWorkspaceEditToContext( for (const uri of uris) { const edits = changesByUri[uri]; if (!edits.length) continue; -<<<<<<< HEAD // Security: reject edits outside workspace roots const inWorkspace = @@ -256,8 +241,6 @@ async function applyWorkspaceEditToContext( failures.push(uri); continue; } -======= ->>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) let view = workspace.getFile(uri)?.getView(); if (!view) { @@ -271,7 +254,6 @@ async function applyWorkspaceEditToContext( if (!view) { failures.push(uri); continue; -<<<<<<< HEAD } // Find the plugin belonging to THIS server, not just any plugin @@ -288,16 +270,6 @@ async function applyWorkspaceEditToContext( continue; } -======= - } - - const plugin = LSPPlugin.get(view); - if (!plugin) { - failures.push(uri); - continue; - } - ->>>>>>> cd0ac6e0 (feat(lsp): fixing LspToPosition adding a helper textEditUtils) const applied = applyTextEdits(plugin, view, edits); if (applied) appliedCount++; else failures.push(uri);