дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
bff5d11346
коміт
df27de217e
5 files changed
+162
-17
No files matched your search
@@ -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). */
|
||||
|
||||
@@ -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(".");
|
||||
}
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
{
|
||||
"Cannot copy reference": "Cannot copy reference",
|
||||
"Connect through:": "Connect through:",
|
||||
"Connect through": "Connect through",
|
||||
"WSL distribution:": "WSL distribution:",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"Cannot copy reference": "无法复制引用",
|
||||
"Group actions": "分组操作",
|
||||
"New Project Group": "新建项目组",
|
||||
"Group name:": "组名称:",
|
||||
|
||||
Reference in new issue
Заблокувати користувача