feat(editor): Copy Reference copies path:line unless the caret is on a declaration's name (#1507)

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 <[email protected]>
Цей коміт міститься в:
Jaw authored and GitHub committed 2026-11-05 16:25:33 +08:00
1 джерело 7a0f60377c
коміт 50986088f9
4 files changed
+53 -18

No files matched your search

+4 -2
Переглянути файл
@@ -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);
+11 -16
Переглянути файл
@@ -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(".")}`;
}
}
+30
Переглянути файл
@@ -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([]);
});
});
+8
Переглянути файл
@@ -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);
}