From 50986088f9be8716f540db69a8d682c059f007e1 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:25:33 +0800 Subject: [PATCH] feat(editor): Copy Reference copies path:line unless the caret is on a declaration's name (#1507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy Reference used the outline symbols enclosing the caret, so anywhere inside a method it copied the method's qualified name and the line was lost. It now names a symbol only with the caret on a declaration's name (its selection range) and copies `path:line` everywhere else, for every language. The `package …;` symbol jdtls reports is no longer qualified, so that line copies `path:1`. Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- src/editor/core/actions.ts | 6 ++++-- src/editor/core/copyReference.ts | 27 ++++++++++-------------- src/editor/core/editingExtras.test.ts | 30 +++++++++++++++++++++++++++ src/editor/core/view/outline.ts | 8 +++++++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index d9c063773..ca1bd6ccc 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -63,7 +63,7 @@ import { cloneCaretAbove, cloneCaretBelow } from "./cm/verticalMotion"; import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras"; import { cyclicExpandWord } from "./wordCompletion"; import { qualifiedReference } from "./copyReference"; -import { symbolChain } from "./view/outline"; +import { declarationChain } from "./view/outline"; import { recordClipboard } from "./clipboardHistory"; import { useDocMeta, useOutline } from "./model/document"; import { fontZoom, setFontZoom, stepFontZoom } from "./cm/prefs"; @@ -414,12 +414,14 @@ function copyReference(view: EditorView, slot: EditorSlot) { const line = state.doc.lineAt(head); 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({ language: meta.language, path: slot.model.path, root: useProject.getState().project?.root ?? null, text: state.doc.toString(), - chain: symbolChain(outline, line.number - 1, head - line.from).map((i) => i.name), + chain: chain.map((i) => i.name), line: line.number - 1, }); recordClipboard(reference); diff --git a/src/editor/core/copyReference.ts b/src/editor/core/copyReference.ts index 9684fd25b..953a73f94 100644 --- a/src/editor/core/copyReference.ts +++ b/src/editor/core/copyReference.ts @@ -1,7 +1,7 @@ /** - * Edit › Copy Reference (⌥⇧⌘C): the fully qualified name of the symbol at - * the caret, built from the file's outline (symbols enclosing the caret) and - * the language's module conventions; `path:line` where there is no symbol. + * Edit › Copy Reference (⌥⇧⌘C): with the caret on a declaration's name, its + * fully qualified name, built from the file's outline and the language's module + * conventions; anywhere else `path:line`, as in IDEA. */ export interface ReferenceInput { @@ -11,7 +11,7 @@ export interface ReferenceInput { /** Project root, for project-relative paths and module names. */ root: string | null; text: string; - /** Names of the outline symbols enclosing the caret, outermost first. */ + /** Names of the outline symbols down to the one named at the caret, outermost first; empty off a name. */ chain: string[]; /** 0-based caret line. */ line: number; @@ -59,42 +59,37 @@ const declared = (text: string, re: RegExp) => re.exec(text)?.[1] ?? null; export function qualifiedReference(r: ReferenceInput): string { const fallback = `${relativePath(r.path, r.root)}:${r.line + 1}`; const chain = r.chain.filter(Boolean); + if (!chain.length) return fallback; switch (r.language) { case "java": case "groovy": { - if (!chain.length) return fallback; const pkg = declared(r.text, /^\s*package\s+([\w.]+)\s*;?/m); const [type, ...rest] = chain; const typePath = [pkg, type].filter(Boolean).join("."); - // `pkg.Outer.Inner#member`: the last symbol is the member unless the caret is in a type only. + // `pkg.Outer.Inner#member`: the last symbol is the member unless the caret is on a type's name. return rest.length ? `${[typePath, ...rest.slice(0, -1)].join(".")}#${rest[rest.length - 1]}` : typePath; } case "kotlin": case "scala": { - if (!chain.length) return fallback; const pkg = declared(r.text, /^\s*package\s+([\w.]+)/m); return [pkg, ...chain].filter(Boolean).join("."); } - case "csharp": { - if (!chain.length) return fallback; + case "csharp": return chain.join("."); - } - case "rust": { - if (!chain.length) return rustModulePath(r.path); + case "rust": return [rustModulePath(r.path), ...chain.map((n) => (n.startsWith("impl") ? rustImplType(n) : n))].join("::"); - } case "python": return [pythonModulePath(r.path, r.root), ...chain].join("."); case "go": { const pkg = declared(r.text, /^\s*package\s+(\w+)/m) ?? stem(r.path); - return chain.length ? [pkg, ...chain].join(".") : fallback; + return [pkg, ...chain].join("."); } case "cpp": case "c": case "objective-cpp": case "php": - return chain.length ? chain.join("::") : fallback; + return chain.join("::"); default: - return chain.length ? `${relativePath(r.path, r.root)}:${chain.join(".")}` : fallback; + return `${relativePath(r.path, r.root)}:${chain.join(".")}`; } } diff --git a/src/editor/core/editingExtras.test.ts b/src/editor/core/editingExtras.test.ts index 8c9676f8c..d1ead564c 100644 --- a/src/editor/core/editingExtras.test.ts +++ b/src/editor/core/editingExtras.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } 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 { declarationChain } from "./view/outline"; describe("cyclic expand word", () => { const text = "alphaOne x alphaTwo y al| alphaThree alphaOne"; @@ -101,5 +103,33 @@ describe("copy reference", () => { expect( qualifiedReference({ ...base, root: null, language: "markdown", path: "/x/a.md", text: "", chain: [] }), ).toBe("/x/a.md:5"); + expect(qualifiedReference({ ...base, language: "python", path: "/p/pkg/mod.py", text: "", chain: [] })).toBe( + "pkg/mod.py:5", + ); + expect(qualifiedReference({ ...base, language: "rust", path: "/p/src/a.rs", text: "", chain: [] })).toBe( + "src/a.rs:5", + ); + }); + + it("names a symbol only with the caret on its name, like IDEA", () => { + const item = (name: string, range: number[], sel: number[], children: OutlineItem[] = []): OutlineItem => ({ + name, + detail: null, + kind: "class", + range: { start: { line: range[0]!, col: range[1]! }, end: { line: range[2]!, col: range[3]! } }, + selection_range: { start: { line: sel[0]!, col: sel[1]! }, end: { line: sel[2]!, col: sel[3]! } }, + children, + }); + // class Book { line 0, name at cols 6-10 + // void sum() { line 1, name at cols 7-10 + // total += 1; line 2 + const outline = [item("Book", [0, 0, 4, 1], [0, 6, 0, 10], [item("sum", [1, 2, 3, 3], [1, 7, 1, 10])])]; + const names = (line: number, col: number) => declarationChain(outline, line, col).map((i) => i.name); + expect(names(1, 8)).toEqual(["Book", "sum"]); + expect(names(1, 10)).toEqual(["Book", "sum"]); + expect(names(0, 6)).toEqual(["Book"]); + expect(names(2, 6)).toEqual([]); + expect(names(1, 2)).toEqual([]); + expect(names(5, 0)).toEqual([]); }); }); diff --git a/src/editor/core/view/outline.ts b/src/editor/core/view/outline.ts index 79451cfe8..ebcc9876d 100644 --- a/src/editor/core/view/outline.ts +++ b/src/editor/core/view/outline.ts @@ -14,3 +14,11 @@ export function symbolChain(items: readonly OutlineItem[], line: number, col: nu level = hit.children; } } + +/** The chain up to the innermost item whose name (selection range) holds (line, col); empty off every name. */ +export function declarationChain(items: readonly OutlineItem[], line: number, col: number): OutlineItem[] { + const pos = { line, col }; + const chain = symbolChain(items, line, col); + const named = chain.findLastIndex((i) => before(i.selection_range.start, pos) && before(pos, i.selection_range.end)); + return chain.slice(0, named + 1); +}