mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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
@@ -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");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user