diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index ca1bd6ccc..4574a17e7 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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 { 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). */ diff --git a/src/editor/core/copyReference.ts b/src/editor/core/copyReference.ts index 953a73f94..618952573 100644 --- a/src/editor/core/copyReference.ts +++ b/src/editor/core/copyReference.ts @@ -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, + clipboard: Clipboard = navigator.clipboard, + Item: typeof ClipboardItem | null = globalThis.ClipboardItem ?? null, +): Promise { + 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("."); } diff --git a/src/editor/core/editingExtras.test.ts b/src/editor/core/editingExtras.test.ts index d1ead564c..335acad9e 100644 --- a/src/editor/core/editingExtras.test.ts +++ b/src/editor/core/editingExtras.test.ts @@ -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((resolve) => { + finish = resolve; + }); + let content: Promise | undefined; + class Item { + constructor(data: Record>) { + 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(); + }); }); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 60c7fb0b8..d7f27a388 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1,4 +1,5 @@ { + "Cannot copy reference": "Cannot copy reference", "Connect through:": "Connect through:", "Connect through": "Connect through", "WSL distribution:": "WSL distribution:", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index dc7007289..e26e17b4c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,5 @@ { + "Cannot copy reference": "无法复制引用", "Group actions": "分组操作", "New Project Group": "新建项目组", "Group name:": "组名称:",