fix(editor): copy references to resolved methods (#1531)

Copy Reference on a call site now resolves the method through Go to Definition and copies its qualified name (crate::Inventory::add in Rust, the dotted module path in Python, and so on); positions without an unambiguous method target copy the project-relative path and line, as before. The clipboard write starts during the key press with a ClipboardItem whose text resolves later, because WebKit rejects a write started after the asynchronous lookup; without ClipboardItem it writes plain text once known. Python module paths also drop everything up to the last src/ directory, for monorepo layouts.

Co-authored-by: zzxzz12345 <[email protected]>
Цей коміт міститься в:
胡翀таzzxzz12345 authored and GitHub committed 2026-11-05 20:57:52 +08:00
1 джерело bff5d11346
коміт df27de217e
5 files changed
+162 -17

No files matched your search

+81 -14
Переглянути файл
@@ -62,7 +62,7 @@ import { isColumnMode, toggleColumnMode } from "./cm/columnMode";
import { cloneCaretAbove, cloneCaretBelow } from "./cm/verticalMotion";
import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras";
import { cyclicExpandWord } from "./wordCompletion";
import { qualifiedReference } from "./copyReference";
import { copyReferenceToClipboard, qualifiedReference, referenceOffset } from "./copyReference";
import { declarationChain } from "./view/outline";
import { recordClipboard } from "./clipboardHistory";
import { useDocMeta, useOutline } from "./model/document";
@@ -84,6 +84,8 @@ import {
import type { DocModel } from "./model/document";
import { structure } from "@/editor/structure";
import { tx } from "@/platform/i18n";
import { lspRequest } from "@/editor/lsp/lsp";
import { readFileLines, withDocument } from "@/editor/lsp/files";
/** Text-editing actions: the editor has focus, or a popup (Find Action) runs them on it. */
export const TEXT_WHEN = "editorTextFocus || textEditorActive && popupOpen";
@@ -408,25 +410,90 @@ function expandWord(view: EditorView, slot: EditorSlot, direction: "backward" |
if (message) void executeAction("editor.showHint", { text: message });
}
function copyReference(view: EditorView, slot: EditorSlot) {
async function resolveReference(view: EditorView, slot: EditorSlot): Promise<string> {
const state = view.state;
const head = state.selection.main.head;
const line = state.doc.lineAt(head);
const selection = state.selection.main;
const line = state.doc.lineAt(selection.from);
const meta = slot.model.getMeta();
const outline = useOutline.getState()[slot.model.path] ?? [];
// A package is not a member to qualify: jdtls lists the whole `package …;` statement as one.
const chain = declarationChain(outline, line.number - 1, head - line.from).filter((i) => i.kind !== "package");
const reference = qualifiedReference({
const root = useProject.getState().project?.root ?? null;
const fallback = qualifiedReference({
language: meta.language,
path: slot.model.path,
root: useProject.getState().project?.root ?? null,
text: state.doc.toString(),
chain: chain.map((i) => i.name),
root,
text: "",
chain: [],
line: line.number - 1,
});
recordClipboard(reference);
void navigator.clipboard?.writeText(reference).catch(() => {});
void executeAction("editor.showHint", { text: tx`Copied reference: ${reference}` });
let reference = fallback;
const offset = referenceOffset(state.doc.toString(), selection.from, selection.to);
if (offset !== null) {
const position = state.doc.lineAt(offset);
const localChain = declarationChain(
useOutline.getState()[slot.model.path] ?? [],
position.number - 1,
offset - position.from,
).filter((item) => item.kind !== "package");
if (localChain.length) {
reference = qualifiedReference({
language: meta.language,
path: slot.model.path,
root,
text: state.doc.toString(),
chain: localChain.map((item) => item.name),
line: position.number - 1,
});
} else {
try {
const targets = await lspRequest(() =>
commands.lspGoto(meta.doc, { line: position.number - 1, col: offset - position.from }, "definition"),
);
if (targets.length === 1) {
const target = targets[0]!;
const [outline, lines] = await Promise.all([
withDocument(target.path, (doc) => call(commands.docOutline(doc))),
readFileLines(target.path),
]);
const pos = target.range.start;
const chain = declarationChain(outline, pos.line, pos.col).filter((item) => item.kind !== "package");
const symbol = chain.at(-1);
if (symbol && ["function", "method", "constructor"].includes(symbol.kind) && lines) {
reference = qualifiedReference({
language: meta.language,
path: target.path,
root,
text: lines.join("\n"),
chain: chain.map((item) => item.name),
line: pos.line,
});
}
}
} catch {
// No unambiguous definition is available; copy the current location.
}
}
}
return reference;
}
function copyReference(view: EditorView, slot: EditorSlot) {
const reference = resolveReference(view, slot);
const failed = (error: unknown) => {
notifications().notify({
level: "error",
title: tx("Cannot copy reference"),
body: error instanceof Error ? error.message : String(error),
});
};
const copied = async () => {
const text = await reference;
recordClipboard(text);
void executeAction("editor.showHint", { text: tx`Copied reference: ${text}` });
};
try {
void copyReferenceToClipboard(reference).then(copied).catch(failed);
} catch (error) {
failed(error);
}
}
/** ⌃G; when nothing more is found, a hint first and the search from the top on the next press (IDEA). */
+23 -1
Переглянути файл
@@ -17,6 +17,27 @@ export interface ReferenceInput {
line: number;
}
/**
* Start the write during the user gesture, while the reference is still being resolved.
* Without `ClipboardItem`, the text is written once it is known.
*/
export function copyReferenceToClipboard(
reference: Promise<string>,
clipboard: Clipboard = navigator.clipboard,
Item: typeof ClipboardItem | null = globalThis.ClipboardItem ?? null,
): Promise<void> {
if (!Item || typeof clipboard.write !== "function") return reference.then((text) => clipboard.writeText(text));
return clipboard.write([
new Item({ "text/plain": reference.then((text) => new Blob([text], { type: "text/plain" })) }),
]);
}
/** Position inside a selected identifier or the identifier under the caret. */
export function referenceOffset(text: string, from: number, to: number): number | null {
if (from !== to) return /^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(text.slice(from, to)) ? from : null;
return /[\p{L}\p{N}_$]/u.test(text[from] ?? "") ? from : null;
}
export function relativePath(path: string, root: string | null): string {
return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
}
@@ -49,7 +70,8 @@ export function pythonModulePath(path: string, root: string | null): string {
const parts = relativePath(path, root)
.replace(/\.pyi?$/, "")
.split("/");
if (parts[0] === "src" && parts.length > 1) parts.shift();
const src = parts.lastIndexOf("src");
if (src >= 0 && src < parts.length - 1) parts.splice(0, src + 1);
if (parts[parts.length - 1] === "__init__") parts.pop();
return parts.join(".");
}
+56 -2
Переглянути файл
@@ -1,9 +1,16 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { cyclicExpandWord, expandCandidates, prefixStart } from "./wordCompletion";
import type { OutlineItem } from "@/platform/ipc";
import { pythonModulePath, qualifiedReference, rustImplType, rustModulePath } from "./copyReference";
import {
copyReferenceToClipboard,
pythonModulePath,
qualifiedReference,
referenceOffset,
rustImplType,
rustModulePath,
} from "./copyReference";
import { declarationChain } from "./view/outline";
describe("cyclic expand word", () => {
@@ -52,6 +59,39 @@ describe("cyclic expand word", () => {
describe("copy reference", () => {
const base = { root: "/p", line: 4 };
it("starts the clipboard write before asynchronous symbol resolution completes", async () => {
let finish!: (value: string) => void;
const reference = new Promise<string>((resolve) => {
finish = resolve;
});
let content: Promise<Blob> | undefined;
class Item {
constructor(data: Record<string, Promise<Blob>>) {
content = data["text/plain"];
}
}
let writes = 0;
const write = () => {
writes++;
return Promise.resolve();
};
const result = copyReferenceToClipboard(
reference,
{ write } as unknown as Clipboard,
Item as unknown as typeof ClipboardItem,
);
expect(writes).toBe(1);
finish("coordination_layer.db.key_scope.load_scoped");
expect(await (await content!).text()).toBe("coordination_layer.db.key_scope.load_scoped");
await result;
});
it("writes the resolved text where ClipboardItem is unavailable", async () => {
const writeText = vi.fn(() => Promise.resolve());
await copyReferenceToClipboard(Promise.resolve("pkg.mod.f"), { writeText } as unknown as Clipboard, null);
expect(writeText).toHaveBeenCalledExactlyOnceWith("pkg.mod.f");
});
it("qualifies Java members with the package", () => {
const text = "package com.acme.util;\n\nclass Strings {}";
expect(
@@ -84,6 +124,9 @@ describe("copy reference", () => {
it("uses dotted module paths for Python and package names for Go", () => {
expect(pythonModulePath("/p/src/pkg/mod.py", "/p")).toBe("pkg.mod");
expect(pythonModulePath("/p/services/live/src/coordination_layer/db/key_scope.py", "/p")).toBe(
"coordination_layer.db.key_scope",
);
expect(pythonModulePath("/p/pkg/__init__.py", "/p")).toBe("pkg");
expect(
qualifiedReference({ ...base, language: "python", path: "/p/pkg/mod.py", text: "", chain: ["C", "m"] }),
@@ -94,6 +137,10 @@ describe("copy reference", () => {
});
it("falls back to path:line", () => {
expect(qualifiedReference({ ...base, language: "python", path: "/p/a.py", text: "", chain: [] })).toBe("a.py:5");
expect(qualifiedReference({ ...base, language: "rust", path: "/p/src/a.rs", text: "", chain: [] })).toBe(
"src/a.rs:5",
);
expect(qualifiedReference({ ...base, language: "java", path: "/p/src/A.java", text: "", chain: [] })).toBe(
"src/A.java:5",
);
@@ -132,4 +179,11 @@ describe("copy reference", () => {
expect(names(1, 2)).toEqual([]);
expect(names(5, 0)).toEqual([]);
});
it("only resolves a selected identifier or an identifier under the caret", () => {
expect(referenceOffset(" load_scoped(x)", 2, 13)).toBe(2);
expect(referenceOffset(" load_scoped(x)", 5, 5)).toBe(5);
expect(referenceOffset(" load_scoped(x)", 0, 0)).toBeNull();
expect(referenceOffset(" load_scoped(x)", 2, 16)).toBeNull();
});
});
+1
Переглянути файл
@@ -1,4 +1,5 @@
{
"Cannot copy reference": "Cannot copy reference",
"Connect through:": "Connect through:",
"Connect through": "Connect through",
"WSL distribution:": "WSL distribution:",
+1
Переглянути файл
@@ -1,4 +1,5 @@
{
"Cannot copy reference": "无法复制引用",
"Group actions": "分组操作",
"New Project Group": "新建项目组",
"Group name:": "组名称:",