feat(editor): Navigate inferred type inlay hints (#77)

Some language servers (basedpyright) send inferred type hints as plain text without a label-part location, so ⌘/Ctrl-clicking `: Series` did nothing. For a type hint right after an identifier, ⌘/Ctrl-click now goes to that identifier's type declaration; hints with locations keep direct navigation. Navigable hints show the link underline and pointer only while ⌘/Ctrl is held, including when it is pressed before hovering.

Co-authored-by: zzxzz12345 <zzxzz12345@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
胡翀
2026-10-27 16:10:19 +08:00
committed by GitHub
co-authored by zzxzz12345
parent 5d2c89b0dd
commit fdd98fbfe4
3 changed files with 76 additions and 3 deletions
+41 -1
View File
@@ -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<typeof import("@/workbench/contributions")>();
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<HTMLElement>(".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);
});
});
+30 -1
View File
@@ -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<InlayHint["kind"], string> = {
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");
}
},
);
+5 -1
View File
@@ -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" },