diff --git a/src/editor/lsp/inlay.test.ts b/src/editor/lsp/inlay.test.ts index 51094375..f4ab2de5 100644 --- a/src/editor/lsp/inlay.test.ts +++ b/src/editor/lsp/inlay.test.ts @@ -4,13 +4,16 @@ import { EditorView } from "@codemirror/view"; import type { InlayHint } from "@/bindings"; const openFile = vi.fn(async () => undefined); +const gotoTargets = vi.fn(async () => undefined); +vi.mock("./navigation", () => ({ gotoTargets })); vi.mock("@/workbench/contributions", async (orig) => { const actual = await orig(); return { ...actual, maybeEditorService: () => ({ openFile }) }; }); -const { inlayDecorations, inlayMenuItems } = await import("./inlay"); +const { inlayDecorations, inlayExtension, inlayMenuItems } = await import("./inlay"); const { usePlatform } = await import("@/platform/platformInfo"); +const { resetNavigationModifier } = await import("@/platform/navigationModifier"); const { useContextMenuStore } = await import("@/ui"); const hint: InlayHint = { @@ -33,6 +36,7 @@ const hint: InlayHint = { const views: EditorView[] = []; afterEach(() => { views.splice(0).forEach((v) => v.destroy()); + resetNavigationModifier(); vi.clearAllMocks(); }); @@ -71,4 +75,40 @@ describe("inlay hints", () => { ]); expect((inlayMenuItems("parameter")[0] as { label: string }).label).toBe("Disable Parameter name hints"); }); + + it("uses type declaration navigation for a plain type hint after an identifier", () => { + usePlatform.setState({ os: "macos" }); + const view = editorWith([{ ...hint, label: ": Foo", parts: [] }]); + const el = view.dom.querySelector(".cm-lsp-inlay")!; + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(gotoTargets).not.toHaveBeenCalled(); + el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, metaKey: true })); + expect(gotoTargets).toHaveBeenCalledWith(view, "typeDefinition", 4); + expect(openFile).not.toHaveBeenCalled(); + }); + + it("does not treat parameter hints or type hints away from an identifier as type links", () => { + const view = editorWith([ + { ...hint, kind: "parameter", parts: [] }, + { ...hint, position: { line: 0, col: 6 }, parts: [] }, + ]); + expect(view.dom.querySelectorAll(".cm-lsp-inlay-part")).toHaveLength(0); + }); + + it.each([ + { os: "macos", key: "Meta", modifier: "metaKey" }, + { os: "windows", key: "Control", modifier: "ctrlKey" }, + ] as const)("tracks the navigation modifier for inlay cursors on $os", ({ os, key, modifier }) => { + usePlatform.setState({ os }); + const view = new EditorView({ + state: EditorState.create({ doc: "let x = foo();", extensions: [inlayExtension()] }), + parent: document.body, + }); + views.push(view); + expect(view.contentDOM.classList.contains("cm-lsp-navigation-modifier")).toBe(false); + window.dispatchEvent(new KeyboardEvent("keydown", { key, [modifier]: true })); + expect(view.contentDOM.classList.contains("cm-lsp-navigation-modifier")).toBe(true); + window.dispatchEvent(new KeyboardEvent("keyup", { key })); + expect(view.contentDOM.classList.contains("cm-lsp-navigation-modifier")).toBe(false); + }); }); diff --git a/src/editor/lsp/inlay.ts b/src/editor/lsp/inlay.ts index 9896aed3..2fc1cc40 100644 --- a/src/editor/lsp/inlay.ts +++ b/src/editor/lsp/inlay.ts @@ -8,7 +8,7 @@ import { StateEffect, StateField, type Extension } from "@codemirror/state"; import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; import { call, commands, isTauri, type InlayHint } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; -import { isNavigationGesture } from "@/platform/navigationModifier"; +import { isNavigationGesture, navigationModifierHeld, onNavigationModifier } from "@/platform/navigationModifier"; import { maybeEditorService } from "@/workbench/contributions"; import { showContextMenu, type MenuItem } from "@/ui"; import { useProject } from "@/platform/project"; @@ -16,6 +16,7 @@ import { editorPrefs } from "@/platform/editorSettings"; import { debounce, docOf, isUnavailable, lspRequest } from "./lsp"; import { offsetsToRange, posToOffset } from "./positions"; import { onLspRefresh, serverBackoff } from "./availability"; +import { gotoTargets } from "./navigation"; const KIND_LABEL: Record = { type: "Type hints", @@ -28,6 +29,13 @@ async function setEditorSetting(key: string, value: unknown) { useProject.setState({ settings: await call(commands.settingsSet("global", `editor.${key}`, JSON.stringify(value))) }); } +/** A type hint after an identifier can resolve that identifier's inferred type. */ +function typeHintOffset(view: EditorView, at: number): number | null { + if (at <= 0 || at > view.state.doc.length) return null; + const word = view.state.wordAt(at - 1); + return word?.to === at ? word.from : null; +} + /** Right-click on a hint: turn its kind off (IDEA's "Disable '…' hints"), or open the settings. */ export function inlayMenuItems(kind: InlayHint["kind"]): MenuItem[] { return [ @@ -81,6 +89,22 @@ class InlayWidget extends WidgetType { el.appendChild(span); } } else el.textContent = this.hint.label; + // A plain type hint (basedpyright's `: Series`) names no location: ⌘-click opens the preceding identifier's type. + if ( + this.hint.kind === "type" && + !parts.some((p) => p.location) && + typeHintOffset(view, posToOffset(view.state.doc, this.hint.position)) !== null + ) { + el.classList.add("cm-lsp-inlay-part"); + el.addEventListener("mousedown", (e) => { + if (e.button !== 0 || !isNavigationGesture(e)) return; + const offset = typeHintOffset(view, view.posAtDOM(el)); + if (offset === null) return; + e.preventDefault(); + e.stopPropagation(); + void gotoTargets(view, "typeDefinition", offset); + }); + } if (this.hint.tooltip) el.title = this.hint.tooltip; el.setAttribute("aria-hidden", "true"); el.addEventListener("contextmenu", (e) => { @@ -131,8 +155,11 @@ const inlayPlugin = ViewPlugin.fromClass( private refresh = debounce(() => void this.run(), 400); private unsubscribe: () => void; private offReady: () => void; + private offModifier: () => void; constructor(private view: EditorView) { this.refresh(); + view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", navigationModifierHeld()); + this.offModifier = onNavigationModifier((held) => view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", held)); this.unsubscribe = useProject.subscribe((s, prev) => { if (s.settings?.editor?.inlayHints !== prev.settings?.editor?.inlayHints || s.settings?.editor?.inlay !== prev.settings?.editor?.inlay) this.refresh(); @@ -167,6 +194,8 @@ const inlayPlugin = ViewPlugin.fromClass( this.refresh.cancel(); this.unsubscribe(); this.offReady(); + this.offModifier(); + this.view.contentDOM.classList.remove("cm-lsp-navigation-modifier"); } }, ); diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index 0246c835..2ddc2cdf 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -328,7 +328,11 @@ export const lspTheme = EditorView.theme({ userSelect: "none", }, // A part naming a declaration (⌘-click navigates there). - ".cm-lsp-inlay-part:hover": { textDecoration: "underline", textUnderlineOffset: "2px" }, + ".cm-lsp-navigation-modifier .cm-lsp-inlay-part:hover": { + textDecoration: "underline", + textUnderlineOffset: "2px", + cursor: "pointer", + }, // Inline completion: the suggestion as gray text at the caret. ".cm-inline-ghost": { color: "var(--fg-subtle)", whiteSpace: "pre" }, ".cm-lsp-inlay-pl": { marginLeft: "4px" },