From 9361d5dd9d8bc8d6ebc06d2b08acac187ba6f53e Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:48:41 +0530 Subject: [PATCH] feat(lsp): improve actions, navigation, and position handling - advertise code action resolve support - clamp LSP positions beyond document bounds - support LocationLink and multiple navigation results - add LSP operations to the selection menu - fix navigation scrolling after switching files - use shared position and location normalization helpers --- src/cm/commandRegistry.js | 10 +- src/cm/lsp/clientManager.ts | 17 +- src/cm/lsp/codeActions.ts | 31 ++-- src/cm/lsp/definition.ts | 147 ++++++++++++++++++ src/cm/lsp/diagnostics.ts | 9 +- src/cm/lsp/documentColors.ts | 5 +- src/cm/lsp/index.ts | 10 ++ src/cm/lsp/inlayHints.ts | 3 +- src/cm/lsp/locationUtils.ts | 26 ++++ src/cm/lsp/positionUtils.ts | 16 ++ src/cm/lsp/references.ts | 4 +- src/cm/lsp/rename.ts | 47 ++++-- src/cm/lsp/tooltipExtensions.ts | 19 ++- src/cm/lsp/types.ts | 7 +- src/cm/touchSelectionMenu.js | 26 ++++ src/components/referencesPanel/utils.js | 29 ++-- src/lib/editorManager.js | 57 +++++-- src/lib/selectionMenu.js | 81 +++++++++- .../lspExternalWebSocketLifecycle.test.js | 18 +++ tests/unit/lspLocationUtils.test.ts | 38 +++++ tests/unit/lspPositionUtils.test.ts | 28 ++++ 21 files changed, 534 insertions(+), 94 deletions(-) create mode 100644 src/cm/lsp/definition.ts create mode 100644 src/cm/lsp/locationUtils.ts create mode 100644 src/cm/lsp/positionUtils.ts create mode 100644 tests/unit/lspLocationUtils.test.ts create mode 100644 tests/unit/lspPositionUtils.test.ts diff --git a/src/cm/commandRegistry.js b/src/cm/commandRegistry.js index 1446027cbb..5d53cb1747 100644 --- a/src/cm/commandRegistry.js +++ b/src/cm/commandRegistry.js @@ -63,13 +63,7 @@ import { } from "@codemirror/lint"; import { LSPPlugin, - closeReferencePanel as lspCloseReferencePanel, - findReferences as lspFindReferences, formatDocument as lspFormatDocument, - jumpToDeclaration as lspJumpToDeclaration, - jumpToDefinition as lspJumpToDefinition, - jumpToImplementation as lspJumpToImplementation, - jumpToTypeDefinition as lspJumpToTypeDefinition, } from "@codemirror/lsp-client"; import { Compartment, EditorSelection } from "@codemirror/state"; import { keymap } from "@codemirror/view"; @@ -91,6 +85,10 @@ import { renameSymbol as acodeRenameSymbol, clearDiagnosticsEffect, clientManager, + jumpToDeclaration as lspJumpToDeclaration, + jumpToDefinition as lspJumpToDefinition, + jumpToImplementation as lspJumpToImplementation, + jumpToTypeDefinition as lspJumpToTypeDefinition, nextSignature as lspNextSignature, prevSignature as lspPrevSignature, showSignatureHelp as lspShowSignatureHelp, diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 11bb6f5401..efd9cb284d 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -24,6 +24,7 @@ import { supportsBuiltinFormatting } from "./formattingSupport"; import { documentColorsExtension } from "./documentColors"; import { inlayHintsExtension } from "./inlayHints"; import { addLspLog } from "./logs"; +import { safeLspPositionToOffset } from "./positionUtils"; import { selectRuntimeProvider } from "./runtimeProviders"; import serverRegistry from "./serverRegistry"; import { @@ -804,6 +805,14 @@ export class LspClientManager { configuration: true, workspaceFolders: true, }, + textDocument: { + codeAction: { + dataSupport: true, + resolveSupport: { + properties: ["edit"], + }, + }, + }, }, }; @@ -1436,12 +1445,8 @@ function applyTextEdits( 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; - } + fromBase = safeLspPositionToOffset(plugin.syncedDoc, edit.range.start); + toBase = safeLspPositionToOffset(plugin.syncedDoc, edit.range.end); const fromResult = plugin.unsyncedChanges.mapPos( fromBase, 1, diff --git a/src/cm/lsp/codeActions.ts b/src/cm/lsp/codeActions.ts index 89811ac560..7e8996e5ad 100644 --- a/src/cm/lsp/codeActions.ts +++ b/src/cm/lsp/codeActions.ts @@ -12,8 +12,9 @@ import type { Range as LspRange, WorkspaceEdit, } from "vscode-languageserver-types"; -import type { Position, Range } from "./types"; +import type { Range } from "./types"; import { addLspLogFor } from "./logs"; +import { safeLspPositionToOffset } from "./positionUtils"; import type AcodeWorkspace from "./workspace"; type CodeActionResponse = (CodeAction | Command)[] | null; @@ -62,13 +63,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, @@ -157,7 +151,7 @@ async function applyChangesToFile( workspace: AcodeWorkspace, uri: string, changes: LspChange[], - mapping: { mapPosition: (uri: string, pos: Position) => number }, + mapping: { mapPos: (uri: string, pos: number, assoc?: number) => number }, ): Promise { const file = workspace.getFile(uri); if (file) { @@ -165,8 +159,16 @@ async function applyChangesToFile( if (view) { view.dispatch({ changes: changes.map((c) => ({ - from: mapping.mapPosition(uri, c.range.start), - to: mapping.mapPosition(uri, c.range.end), + from: mapping.mapPos( + uri, + safeLspPositionToOffset(file.doc, c.range.start), + 1, + ), + to: mapping.mapPos( + uri, + safeLspPositionToOffset(file.doc, c.range.end), + -1, + ), insert: c.newText, })), userEvent: "codeAction", @@ -188,8 +190,11 @@ async function applyChangesToFile( displayedView.dispatch({ changes: changes.map((c) => ({ - from: lspPositionToOffset(displayedView.state.doc, c.range.start), - to: lspPositionToOffset(displayedView.state.doc, c.range.end), + from: safeLspPositionToOffset( + displayedView.state.doc, + c.range.start, + ), + to: safeLspPositionToOffset(displayedView.state.doc, c.range.end), insert: c.newText, })), userEvent: "codeAction", diff --git a/src/cm/lsp/definition.ts b/src/cm/lsp/definition.ts new file mode 100644 index 0000000000..53d3726ed7 --- /dev/null +++ b/src/cm/lsp/definition.ts @@ -0,0 +1,147 @@ +import { LSPPlugin } from "@codemirror/lsp-client"; +import type { Command, EditorView } from "@codemirror/view"; +import { showReferencesPanel } from "components/referencesPanel"; +import { navigateToReference } from "components/referencesPanel/utils"; +import toast from "components/toast"; +import type { ServerCapabilities } from "vscode-languageserver-protocol"; +import { normalizeLocations, type LspLocationResult } from "./locationUtils"; +import { addLspLogFor } from "./logs"; +import { fetchLineText, getWordAtCursor } from "./references"; + +type DefinitionKind = + | "definition" + | "declaration" + | "implementation" + | "typeDefinition"; + +const CAPABILITY: Record = { + definition: "definitionProvider", + declaration: "declarationProvider", + implementation: "implementationProvider", + typeDefinition: "typeDefinitionProvider", +}; + +const LABEL: Record = { + definition: "definition", + declaration: "declaration", + implementation: "implementation", + typeDefinition: "type definition", +}; + +function locationKey(location: ReturnType[number]) { + const { start, end } = location.range; + return `${location.uri}:${start.line}:${start.character}:${end.line}:${end.character}`; +} + +async function fetchLocations( + view: EditorView, + kind: DefinitionKind, +): Promise | null> { + const plugins = LSPPlugin.getAll(view, kind).filter( + (plugin) => !!plugin.client.serverCapabilities?.[CAPABILITY[kind]], + ); + if (!plugins.length) { + toast(`Language server does not support go to ${LABEL[kind]}`); + return null; + } + + const position = view.state.selection.main.head; + const settled = await Promise.allSettled( + plugins.map(async (plugin) => { + plugin.client.sync(); + return plugin.client.request< + { + textDocument: { uri: string }; + position: { line: number; character: number }; + }, + LspLocationResult + >(`textDocument/${kind}`, { + textDocument: { uri: plugin.uri }, + position: plugin.toPosition(position), + }); + }), + ); + + const locations: ReturnType = []; + const seen = new Set(); + for (let index = 0; index < settled.length; index++) { + const result = settled[index]; + if (result.status === "rejected") { + addLspLogFor( + plugins[index], + "warn", + `Go to ${LABEL[kind]} failed`, + result.reason, + ); + continue; + } + for (const location of normalizeLocations(result.value)) { + const key = locationKey(location); + if (seen.has(key)) continue; + seen.add(key); + locations.push(location); + } + } + return locations; +} + +async function goTo(view: EditorView, kind: DefinitionKind): Promise { + try { + const locations = await fetchLocations(view, kind); + if (locations === null) return false; + if (!locations.length) { + toast(`No ${LABEL[kind]} found`); + return true; + } + + if (locations.length === 1) { + await navigateToReference(locations[0]); + return true; + } + + const symbolName = getWordAtCursor(view); + const panel = showReferencesPanel({ symbolName }); + panel.setReferences( + await Promise.all( + locations.map(async (location) => ({ + ...location, + lineText: await fetchLineText( + location.uri, + location.range.start.line, + ), + })), + ), + ); + return true; + } catch (error) { + console.error(`[LSP:Definition] Go to ${LABEL[kind]} failed:`, error); + return false; + } +} + +export const jumpToDefinition: Command = (view) => { + void goTo(view, "definition"); + return true; +}; + +export const jumpToDeclaration: Command = (view) => { + void goTo(view, "declaration"); + return true; +}; + +export const jumpToImplementation: Command = (view) => { + void goTo(view, "implementation"); + return true; +}; + +export const jumpToTypeDefinition: Command = (view) => { + void goTo(view, "typeDefinition"); + return true; +}; + +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"); diff --git a/src/cm/lsp/diagnostics.ts b/src/cm/lsp/diagnostics.ts index 906c505167..61e670ac73 100644 --- a/src/cm/lsp/diagnostics.ts +++ b/src/cm/lsp/diagnostics.ts @@ -10,6 +10,7 @@ import { } from "@codemirror/state"; import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import { addLspLogFor } from "./logs"; +import { safeLspPositionToOffset } from "./positionUtils"; import type { DocumentDiagnosticParams, DocumentDiagnosticReport, @@ -186,13 +187,13 @@ function collectLspDiagnostics( let from: number; let to: number; try { - const mappedFrom = plugin.fromPosition( - diagnostic.range.start, + const mappedFrom = safeLspPositionToOffset( plugin.syncedDoc, + diagnostic.range.start, ); - const mappedTo = plugin.fromPosition( - diagnostic.range.end, + const mappedTo = safeLspPositionToOffset( plugin.syncedDoc, + diagnostic.range.end, ); const fromResult = plugin.unsyncedChanges.mapPos(mappedFrom); const toResult = plugin.unsyncedChanges.mapPos(mappedTo); diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts index 11496259fd..6c64c7540d 100644 --- a/src/cm/lsp/documentColors.ts +++ b/src/cm/lsp/documentColors.ts @@ -36,6 +36,7 @@ import { type ColorChipPayload, } from "../colorChip"; import type { LSPPluginAPI } from "./types"; +import { safeLspPositionToOffset } from "./positionUtils"; export interface DocumentColorsConfig { enabled?: boolean; @@ -178,8 +179,8 @@ function mapLspRange( let from: number; let to: number; try { - const fromBase = lsp.fromPosition(range.start, lsp.syncedDoc); - const toBase = lsp.fromPosition(range.end, lsp.syncedDoc); + const fromBase = safeLspPositionToOffset(lsp.syncedDoc, range.start); + const toBase = safeLspPositionToOffset(lsp.syncedDoc, range.end); const fromMapped = lsp.unsyncedChanges.mapPos( fromBase, 1, diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index f3a8b4d36a..e165f91b17 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -89,6 +89,16 @@ export { findAllReferences, findAllReferencesInTab, } from "./references"; +export { + goToDeclaration, + goToDefinition, + goToImplementation, + goToTypeDefinition, + jumpToDeclaration, + jumpToDefinition, + jumpToImplementation, + jumpToTypeDefinition, +} from "./definition"; export { acodeRenameExtension, acodeRenameKeymap, diff --git a/src/cm/lsp/inlayHints.ts b/src/cm/lsp/inlayHints.ts index 477c82d1b2..112ec3e5b3 100644 --- a/src/cm/lsp/inlayHints.ts +++ b/src/cm/lsp/inlayHints.ts @@ -22,6 +22,7 @@ import type { Position, } from "vscode-languageserver-types"; import type { LSPPluginAPI } from "./types"; +import { safeLspPositionToOffset } from "./positionUtils"; // ============================================================================ // Types @@ -238,7 +239,7 @@ function createPlugin(config: InlayHintsConfig) { let pos: number; try { - pos = lsp.fromPosition(h.position, lsp.syncedDoc); + pos = safeLspPositionToOffset(lsp.syncedDoc, h.position); const mapped = lsp.unsyncedChanges.mapPos(pos); if (mapped === null) continue; pos = mapped; diff --git a/src/cm/lsp/locationUtils.ts b/src/cm/lsp/locationUtils.ts new file mode 100644 index 0000000000..672c6a2744 --- /dev/null +++ b/src/cm/lsp/locationUtils.ts @@ -0,0 +1,26 @@ +import type { + Location, + LocationLink, +} from "vscode-languageserver-protocol"; + +export type LspLocationResult = + | Location + | Location[] + | LocationLink[] + | null; + +/** Normalize definition-style responses to the Location shape used by Acode. */ +export function normalizeLocations(result: LspLocationResult): Location[] { + if (!result) return []; + const locations = Array.isArray(result) ? result : [result]; + + return locations.map((location) => { + if ("targetUri" in location) { + return { + uri: location.targetUri, + range: location.targetSelectionRange ?? location.targetRange, + }; + } + return location; + }); +} diff --git a/src/cm/lsp/positionUtils.ts b/src/cm/lsp/positionUtils.ts new file mode 100644 index 0000000000..158afe188e --- /dev/null +++ b/src/cm/lsp/positionUtils.ts @@ -0,0 +1,16 @@ +import type { Text } from "@codemirror/state"; + +/** Convert an LSP position to an offset without reading outside the document. */ +export function safeLspPositionToOffset( + doc: Pick, + position: { line: number; character: number }, +): number { + if (position.line < 0) return 0; + if (position.line >= doc.lines) return doc.length; + + const line = doc.line(position.line + 1); + const character = Number.isFinite(position.character) + ? Math.max(0, Math.min(position.character, line.length)) + : 0; + return line.from + character; +} diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index def69288c3..cb02fe207d 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 75b89693cb..5e4c93b50b 100644 --- a/src/cm/lsp/rename.ts +++ b/src/cm/lsp/rename.ts @@ -8,6 +8,7 @@ import { import prompt from "dialogs/prompt"; import type * as lsp from "vscode-languageserver-protocol"; import { addLspLogFor } from "./logs"; +import { safeLspPositionToOffset } from "./positionUtils"; import type AcodeWorkspace from "./workspace"; interface RenameParams { @@ -99,12 +100,24 @@ async function performRename(view: EditorView): Promise { ) { initialValue = word; } else if ("start" in prepareResult && "end" in prepareResult) { - const from = plugin.fromPosition(prepareResult.start); - const to = plugin.fromPosition(prepareResult.end); + const from = safeLspPositionToOffset( + view.state.doc, + prepareResult.start, + ); + const to = safeLspPositionToOffset( + view.state.doc, + prepareResult.end, + ); initialValue = view.state.sliceDoc(from, to); } else if ("range" in prepareResult && prepareResult.range) { - const from = plugin.fromPosition(prepareResult.range.start); - const to = plugin.fromPosition(prepareResult.range.end); + const from = safeLspPositionToOffset( + view.state.doc, + prepareResult.range.start, + ); + const to = safeLspPositionToOffset( + view.state.doc, + prepareResult.range.end, + ); initialValue = view.state.sliceDoc(from, to); } } @@ -148,19 +161,11 @@ 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, lspChanges: LspChange[], - mapping: { mapPosition: (uri: string, pos: lsp.Position) => number }, + mapping: { mapPos: (uri: string, pos: number, assoc?: number) => number }, ): Promise { const file = workspace.getFile(uri); @@ -169,8 +174,16 @@ async function applyChangesToFile( if (view) { view.dispatch({ changes: lspChanges.map((change) => ({ - from: mapping.mapPosition(uri, change.range.start), - to: mapping.mapPosition(uri, change.range.end), + from: mapping.mapPos( + uri, + safeLspPositionToOffset(file.doc, change.range.start), + 1, + ), + to: mapping.mapPos( + uri, + safeLspPositionToOffset(file.doc, change.range.end), + -1, + ), insert: change.newText, })), userEvent: "rename", @@ -189,8 +202,8 @@ async function applyChangesToFile( const doc = displayedView.state.doc; displayedView.dispatch({ changes: lspChanges.map((change) => ({ - from: lspPositionToOffset(doc, change.range.start), - to: lspPositionToOffset(doc, change.range.end), + from: safeLspPositionToOffset(doc, change.range.start), + to: safeLspPositionToOffset(doc, change.range.end), insert: change.newText, })), userEvent: "rename", diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index 2a9a157ea0..7332d12a2c 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 { safeLspPositionToOffset } from "./positionUtils"; interface LspClientInternals { config?: { @@ -258,14 +259,6 @@ async function loadHoverContentLanguages(contents: Hover["contents"]): Promise"']/g, (match) => { switch (match) { @@ -401,8 +394,14 @@ function lspTooltipSource( let to = pos; for (const { result } of results) { if (!result.range) continue; - from = Math.min(from, fromPosition(view.state.doc, result.range.start)); - to = Math.max(to, fromPosition(view.state.doc, result.range.end)); + from = Math.min( + from, + safeLspPositionToOffset(view.state.doc, result.range.start), + ); + to = Math.max( + to, + safeLspPositionToOffset(view.state.doc, result.range.end), + ); } return { diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index ad47e72df0..6f39038a42 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -631,13 +631,8 @@ export interface LSPPluginAPI { client: LSPClient & { sync: () => void; connected?: boolean }; /** Convert a document offset to an LSP Position */ toPosition: (offset: number) => { line: number; character: number }; - /** Convert an LSP Position to a document offset */ - fromPosition: ( - pos: { line: number; character: number }, - doc?: unknown, - ) => number; /** The currently synced document state */ - syncedDoc: { length: number }; + syncedDoc: Text; /** Pending changes that haven't been synced yet */ unsyncedChanges: { mapPos: (pos: number, assoc?: number, mode?: MapMode) => number | null; diff --git a/src/cm/touchSelectionMenu.js b/src/cm/touchSelectionMenu.js index c61b7bc060..b017acb1fb 100644 --- a/src/cm/touchSelectionMenu.js +++ b/src/cm/touchSelectionMenu.js @@ -140,6 +140,31 @@ function hasCodeActionProvider(view) { ); } +function hasLspActions(view) { + const capabilities = [ + ["definition", "definitionProvider"], + ["declaration", "declarationProvider"], + ["implementation", "implementationProvider"], + ["typeDefinition", "typeDefinitionProvider"], + ["references", "referencesProvider"], + ]; + if ( + capabilities.some(([feature, capability]) => + LSPPlugin.getAll(view, feature).some( + (plugin) => !!plugin.client.serverCapabilities?.[capability], + ), + ) + ) { + return true; + } + return ( + !view.state.readOnly && + LSPPlugin.getAll(view, "rename").some( + (plugin) => !!plugin.client.serverCapabilities?.renameProvider, + ) + ); +} + function animationsDisabled() { return ( document.body.classList.contains("no-animation") || @@ -665,6 +690,7 @@ class TouchSelectionMenuController { const items = filterSelectionMenuItems( selectionMenu({ codeActionsAvailable: hasCodeActionProvider(this.#view), + lspActionsAvailable: hasLspActions(this.#view), }), { readOnly: this.#isReadOnly(), diff --git a/src/components/referencesPanel/utils.js b/src/components/referencesPanel/utils.js index d88a9ca84f..d02de6ca54 100644 --- a/src/components/referencesPanel/utils.js +++ b/src/components/referencesPanel/utils.js @@ -1,5 +1,5 @@ -import { EditorView } from "@codemirror/view"; import { focusEditorIfEditable } from "cm/editorReadOnly"; +import { safeLspPositionToOffset } from "cm/lsp/positionUtils"; import Sidebar from "components/sidebar"; import DOMPurify from "dompurify"; import openFile from "lib/openFile"; @@ -117,19 +117,22 @@ export async function navigateToReference(ref) { if (!editor) return; const doc = editor.state.doc; - const startLine = doc.line(ref.range.start.line + 1); - const endLine = doc.line(ref.range.end.line + 1); - const from = Math.min( - startLine.from + ref.range.start.character, - startLine.to, - ); - const to = Math.min(endLine.from + ref.range.end.character, endLine.to); + const from = safeLspPositionToOffset(doc, ref.range.start); + const to = safeLspPositionToOffset(doc, ref.range.end); - editor.dispatch({ - selection: { anchor: from, head: to }, - effects: EditorView.scrollIntoView(from, { y: "center" }), - }); - focusEditorIfEditable(editor); + if (typeof editorManager.revealRange === "function") { + editorManager.revealRange(from, to, { + y: "center", + userEvent: "select.definition", + }); + } else { + editor.dispatch({ + selection: { anchor: from, head: to }, + scrollIntoView: true, + userEvent: "select.definition", + }); + focusEditorIfEditable(editor); + } } catch (error) { console.error("Failed to navigate to reference:", error); } diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index d216b4b933..3ce9068465 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -1850,13 +1850,7 @@ async function EditorManager($header, $body) { const col = Math.max(0, Math.min(targetColumn, docLine.length)); const pos = docLine.from + col; - // Move cursor and scroll into view - editor.dispatch({ - selection: { anchor: pos, head: pos }, - effects: EditorView.scrollIntoView(pos, { y: "center" }), - }); - focusEditorIfEditable(editor); - return true; + return revealEditorRange(editor, pos); } catch (error) { console.error("Error in gotoLine:", error); return false; @@ -2181,12 +2175,7 @@ async function EditorManager($header, $body) { const col = Math.max(0, Math.min(targetColumn, docLine.length)); const pos = docLine.from + col; - targetEditor.dispatch({ - selection: { anchor: pos, head: pos }, - effects: EditorView.scrollIntoView(pos, { y: "center" }), - }); - focusEditorIfEditable(targetEditor); - return true; + return revealEditorRange(targetEditor, pos); } catch (error) { console.error("Error in gotoLine:", error); return false; @@ -3192,6 +3181,9 @@ async function EditorManager($header, $body) { hasUnsavedFiles, getEditorHeight, getEditorWidth, + revealRange(from, to = from, options) { + return revealEditorRange(manager.editor, from, to, options); + }, header: $header, openPreviousEditorFromHistory, openNextEditorFromHistory, @@ -3452,6 +3444,45 @@ async function EditorManager($header, $body) { applyFileToEditor(file, { forceRecreate: true }); } + /** + * Reveal an editor range after a file switch. + * + * File activation restores the tab's saved viewport over two animation + * frames and a short timeout. Explicit navigation must cancel that work or + * it can overwrite CodeMirror's scrollIntoView effect while leaving the new + * selection in place. + */ + function revealEditorRange( + targetEditor, + from, + to = from, + { y = "center", userEvent = "select.reveal" } = {}, + ) { + if (!targetEditor) return false; + + try { + const length = targetEditor.state.doc.length; + const anchor = Math.max(0, Math.min(Number(from) || 0, length)); + const head = Math.max(0, Math.min(Number(to) || 0, length)); + + if (targetEditor === editor) { + cancelPendingScrollRestore(); + clearScrollbarScrollLock(); + } + + targetEditor.dispatch({ + selection: { anchor, head }, + effects: EditorView.scrollIntoView(anchor, { y }), + userEvent, + }); + focusEditorIfEditable(targetEditor); + return true; + } catch (error) { + console.error("Error revealing editor range:", error); + return false; + } + } + appSettings.on("update:tabSize", function () { updateEditorIndentationSettings(); }); diff --git a/src/lib/selectionMenu.js b/src/lib/selectionMenu.js index 333333fb8e..f071d62bd8 100644 --- a/src/lib/selectionMenu.js +++ b/src/lib/selectionMenu.js @@ -27,10 +27,78 @@ const showCodeActions = async () => { } }; +const showLspActions = async () => { + const { editor } = editorManager; + if (!editor) return; + + try { + const lsp = await import("cm/lsp"); + const { LSPPlugin } = await import("@codemirror/lsp-client"); + const hasCapability = (feature, capability) => + LSPPlugin.getAll(editor, feature).some( + (plugin) => !!plugin.client.serverCapabilities?.[capability], + ); + const editable = !editor.state.readOnly; + const actions = [ + hasCapability("definition", "definitionProvider") && { + value: "definition", + text: getLabel("go to definition", "Go to Definition"), + icon: "keyboard_arrow_right", + run: lsp.goToDefinition, + }, + hasCapability("declaration", "declarationProvider") && { + value: "declaration", + text: getLabel("go to declaration", "Go to Declaration"), + icon: "keyboard_arrow_right", + run: lsp.goToDeclaration, + }, + hasCapability("implementation", "implementationProvider") && { + value: "implementation", + text: getLabel("go to implementation", "Go to Implementation"), + icon: "keyboard_arrow_right", + run: lsp.goToImplementation, + }, + hasCapability("typeDefinition", "typeDefinitionProvider") && { + value: "type-definition", + text: getLabel("go to type definition", "Go to Type Definition"), + icon: "keyboard_arrow_right", + run: lsp.goToTypeDefinition, + }, + hasCapability("references", "referencesProvider") && { + value: "references", + text: getLabel("find references", "Find References"), + icon: "linkinsert_link", + run: lsp.findAllReferences, + }, + editable && + hasCapability("rename", "renameProvider") && { + value: "rename", + text: getLabel("rename symbol", "Rename Symbol"), + icon: "edit", + run: lsp.renameSymbol, + }, + ].filter(Boolean); + + if (!actions.length) return; + // Let the tap/click that opened this picker finish before its rows exist. + // Otherwise Android WebView can deliver that click to the first row. + await new Promise((resolve) => setTimeout(resolve, 0)); + const { default: select } = await import("dialogs/select"); + const selected = await select( + getLabel("lsp actions", "LSP Actions"), + actions, + ).catch(() => null); + const action = actions.find((item) => item.value === selected); + if (action) await action.run(editor); + } catch (error) { + console.warn("[SelectionMenu] LSP actions not available:", error); + } +}; + const items = []; export default function selectionMenu(options = {}) { - const { codeActionsAvailable = true } = options; + const { codeActionsAvailable = true, lspActionsAvailable = true } = options; return [ item( () => exec("copy"), @@ -92,6 +160,17 @@ export default function selectionMenu(options = {}) { label: getLabel("code actions", "Code Actions"), }, ), + lspActionsAvailable && + item( + () => showLspActions(), + , + "all", + true, + { + id: "lsp-actions", + label: getLabel("lsp actions", "LSP Actions"), + }, + ), ...items, ].filter(Boolean); } diff --git a/tests/unit/lspExternalWebSocketLifecycle.test.js b/tests/unit/lspExternalWebSocketLifecycle.test.js index 65b66dbcbc..227061a14d 100644 --- a/tests/unit/lspExternalWebSocketLifecycle.test.js +++ b/tests/unit/lspExternalWebSocketLifecycle.test.js @@ -197,6 +197,24 @@ afterEach(async () => { }); describe("LSP client idle lifecycle", () => { + it("advertises lazy code-action edit resolution", async () => { + manager = new LspClientManager(); + await manager.getExtensionsForFile({ + uri: "file:///workspace/main.rs", + rootUri: "file:///workspace", + languageId: LANGUAGE_ID, + view, + }); + + const initialize = TestWebSocket.instances[0].sent + .map((data) => JSON.parse(data)) + .find((message) => message.method === "initialize"); + expect(initialize.params.capabilities.textDocument.codeAction).toEqual({ + dataSupport: true, + resolveSupport: {properties: ["edit"]}, + }); + }); + it("reuses an external WebSocket client when a file attaches during the grace period", async () => { const onClientIdle = vi.fn(({dispose}) => void dispose()); manager = new LspClientManager({onClientIdle}); diff --git a/tests/unit/lspLocationUtils.test.ts b/tests/unit/lspLocationUtils.test.ts new file mode 100644 index 0000000000..8a006dd372 --- /dev/null +++ b/tests/unit/lspLocationUtils.test.ts @@ -0,0 +1,38 @@ +import { normalizeLocations } from "cm/lsp/locationUtils"; +import { describe, expect, it } from "vitest"; + +const targetRange = { + start: { line: 4, character: 1 }, + end: { line: 4, character: 8 }, +}; + +describe("normalizeLocations", () => { + it("keeps Location responses unchanged", () => { + const location = { uri: "file:///project/a.ts", range: targetRange }; + expect(normalizeLocations(location)).toEqual([location]); + }); + + it("uses a LocationLink target selection range when present", () => { + const selection = { + start: { line: 4, character: 3 }, + end: { line: 4, character: 6 }, + }; + expect( + normalizeLocations([ + { + targetUri: "file:///project/a.ts", + targetRange, + targetSelectionRange: selection, + }, + ]), + ).toEqual([{ uri: "file:///project/a.ts", range: selection }]); + }); + + it("falls back to the LocationLink target range", () => { + expect( + normalizeLocations([ + { targetUri: "file:///project/a.ts", targetRange }, + ]), + ).toEqual([{ uri: "file:///project/a.ts", range: targetRange }]); + }); +}); diff --git a/tests/unit/lspPositionUtils.test.ts b/tests/unit/lspPositionUtils.test.ts new file mode 100644 index 0000000000..789e4ff250 --- /dev/null +++ b/tests/unit/lspPositionUtils.test.ts @@ -0,0 +1,28 @@ +import { Text } from "@codemirror/state"; +import { safeLspPositionToOffset } from "cm/lsp/positionUtils"; +import { describe, expect, it } from "vitest"; + +describe("safeLspPositionToOffset", () => { + const doc = Text.of(["alpha", "beta"]); + + it("converts valid positions and clamps oversized characters", () => { + expect(safeLspPositionToOffset(doc, { line: 0, character: 2 })).toBe(2); + expect(safeLspPositionToOffset(doc, { line: 1, character: 99 })).toBe( + doc.length, + ); + }); + + it("accepts the exclusive one-past-EOF position used by LSP edits", () => { + expect( + safeLspPositionToOffset(doc, { line: doc.lines, character: 0 }), + ).toBe(doc.length); + expect( + safeLspPositionToOffset(doc, { line: doc.lines + 10, character: 4 }), + ).toBe(doc.length); + }); + + it("clamps negative lines and characters", () => { + expect(safeLspPositionToOffset(doc, { line: -1, character: 20 })).toBe(0); + expect(safeLspPositionToOffset(doc, { line: 1, character: -4 })).toBe(6); + }); +});