дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(lsp): report incomplete rename and caller searches, make them cancellable (#472)
Rename and incoming calls open the project files that mention the name, like Find Usages, but dropped the reason a search could miss usages and could not be cancelled. A rename past the 300-file limit changed part of the usages without saying so. - Rename and call hierarchy return why the result may be incomplete and take a request id for cancel_request. - A rename that may have missed usages always opens the preview, with a warning. - The Callers tree shows the note above the tree or on the expanded node. - Candidates too large or not readable as text are reported as skipped. - A newer rename or hierarchy query cancels the one it replaces.
Цей коміт міститься в:
1 джерело
33d8d87ca2
коміт
5bab140968
18 files changed
+492
-94
No files matched your search
@@ -23,6 +23,13 @@
|
||||
margin: 0 4px;
|
||||
background: var(--border);
|
||||
}
|
||||
.note {
|
||||
flex: none;
|
||||
padding: 0 8px 4px;
|
||||
font-size: var(--fs-small);
|
||||
font-style: italic;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -111,7 +111,7 @@ function HierarchyToolbar() {
|
||||
}
|
||||
|
||||
export function HierarchyView() {
|
||||
const { mode, direction, root, children, failed, loading, generation } = useHierarchy();
|
||||
const { mode, direction, root, children, failed, notes, loading, generation } = useHierarchy();
|
||||
const projectRoot = useProject((s) => s.project?.root ?? "");
|
||||
const roots = useMemo<Node[]>(() => (root ? [{ kind: "root", id: ROOT_ID }] : []), [root]);
|
||||
|
||||
@@ -183,6 +183,11 @@ export function HierarchyView() {
|
||||
{basename(item.path)}:{item.selectionRange.start.line + 1}
|
||||
</span>
|
||||
{calls > 1 && <span className={styles.muted}>{tx`(${calls} calls)`}</span>}
|
||||
{notes.has(n.id) && (
|
||||
<span className={styles.muted} title={notes.get(n.id)}>
|
||||
{notes.get(n.id)}
|
||||
</span>
|
||||
)}
|
||||
{failed.has(n.id) && (
|
||||
<span className={styles.error}>
|
||||
<T>failed to load</T>
|
||||
@@ -195,6 +200,11 @@ export function HierarchyView() {
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<HierarchyToolbar />
|
||||
{notes.has(ROOT_ID) && (
|
||||
<div className={styles.note} role="note">
|
||||
{notes.get(ROOT_ID)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Tree<Node>
|
||||
key={generation}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HierarchyItem, OutlineItem, SymbolKind } from "@/bindings";
|
||||
import type { HierarchyItem, OutlineItem, ReferencesIncomplete, SymbolKind } from "@/bindings";
|
||||
|
||||
const hint = vi.fn();
|
||||
vi.mock("@/workbench/contributions", async (orig) => ({
|
||||
@@ -49,6 +49,12 @@ const item = (name: string, fromRanges: HierarchyItem["fromRanges"] = []): Hiera
|
||||
fromRanges,
|
||||
});
|
||||
|
||||
const callHierarchy = vi.fn((): { items: HierarchyItem[]; incomplete: ReferencesIncomplete | null } => ({
|
||||
items: [item("main", [range(12, 4, 12, 8)])],
|
||||
incomplete: null,
|
||||
}));
|
||||
const cancelRequest = vi.fn(async () => undefined);
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
@@ -57,10 +63,11 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
commands: {
|
||||
...real.commands,
|
||||
docOutline: async () => OUTLINE,
|
||||
lspCallHierarchy: async (_doc: number, pos: { line: number; col: number }) => {
|
||||
cancelRequest,
|
||||
lspCallHierarchy: async (_request: number, _doc: number, pos: { line: number; col: number }) => {
|
||||
// The server prepares a hierarchy only on the method name.
|
||||
if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" });
|
||||
return [item("main", [range(12, 4, 12, 8)])];
|
||||
return callHierarchy();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -73,7 +80,15 @@ vi.mock("./ipc", async (orig) => ({
|
||||
hint: (...a: unknown[]) => hint(...a),
|
||||
}));
|
||||
|
||||
const { enclosingDeclaration, hierarchyTarget, showHierarchy, useHierarchy, ROOT_ID } = await import("./hierarchy");
|
||||
const {
|
||||
enclosingDeclaration,
|
||||
hierarchyTarget,
|
||||
refreshHierarchy,
|
||||
resetHierarchy,
|
||||
showHierarchy,
|
||||
useHierarchy,
|
||||
ROOT_ID,
|
||||
} = await import("./hierarchy");
|
||||
|
||||
describe("hierarchy", () => {
|
||||
it("finds the innermost enclosing declaration of the wanted kinds", () => {
|
||||
@@ -99,6 +114,33 @@ describe("hierarchy", () => {
|
||||
expect(s.root).toEqual({ name: "list", path: "/p/Orders.java", line: 2, col: 7 });
|
||||
expect(s.children.get(ROOT_ID)?.map((n) => n.item.name)).toEqual(["main"]);
|
||||
expect(s.failed.size).toBe(0);
|
||||
expect(s.notes.size).toBe(0);
|
||||
expect(hint).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notes a level whose callers may be incomplete, until it is loaded again", async () => {
|
||||
useHierarchy.setState({ mode: "call", direction: "incoming" });
|
||||
callHierarchy.mockReturnValueOnce({ items: [item("main")], incomplete: "tooManyFiles" });
|
||||
await showHierarchy("call");
|
||||
expect(useHierarchy.getState().notes.get(ROOT_ID)).toBe(
|
||||
"May be incomplete: too many files mention 'list' to search them all",
|
||||
);
|
||||
refreshHierarchy();
|
||||
await vi.waitFor(() => expect(useHierarchy.getState().children.get(ROOT_ID)).toBeDefined());
|
||||
expect(useHierarchy.getState().notes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels the requests of a tree that is replaced", async () => {
|
||||
useHierarchy.setState({ mode: "call", direction: "incoming" });
|
||||
await showHierarchy("call");
|
||||
cancelRequest.mockClear();
|
||||
let answer: (v: ReturnType<typeof callHierarchy>) => void = () => {};
|
||||
callHierarchy.mockReturnValueOnce(new Promise((resolve) => (answer = resolve)) as never);
|
||||
refreshHierarchy();
|
||||
await vi.waitFor(() => expect(callHierarchy).toHaveBeenCalled());
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
resetHierarchy();
|
||||
expect(cancelRequest).toHaveBeenCalledTimes(1);
|
||||
answer({ items: [item("stale")], incomplete: null });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Call / type hierarchy state, with lazily expanded levels. */
|
||||
import { create } from "zustand";
|
||||
import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, SymbolKind } from "@/bindings";
|
||||
import { IpcError, call, commands } from "@/platform/ipc";
|
||||
import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, RequestId, SymbolKind } from "@/bindings";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { IpcError, call, commands, nextRequestId } from "@/platform/ipc";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import { basename } from "@/platform/project";
|
||||
import { caretContext, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc";
|
||||
@@ -29,6 +30,8 @@ interface HierarchyState {
|
||||
children: ReadonlyMap<string, HierarchyNode[]>;
|
||||
/** Parent ids whose children failed to load. */
|
||||
failed: ReadonlySet<string>;
|
||||
/** Parent id → why its children may be missing callers. */
|
||||
notes: ReadonlyMap<string, string>;
|
||||
loading: boolean;
|
||||
/** Bumped per (re)query so stale responses are dropped and the tree resets. */
|
||||
generation: number;
|
||||
@@ -42,16 +45,27 @@ export const useHierarchy = create<HierarchyState>(() => ({
|
||||
root: null,
|
||||
children: new Map(),
|
||||
failed: new Set(),
|
||||
notes: new Map(),
|
||||
loading: false,
|
||||
generation: 0,
|
||||
}));
|
||||
|
||||
/** Call hierarchy requests the current tree still waits for. */
|
||||
const inflight = new Set<RequestId>();
|
||||
|
||||
function cancelInflight() {
|
||||
for (const request of inflight) void commands.cancelRequest(request).catch(() => {});
|
||||
inflight.clear();
|
||||
}
|
||||
|
||||
/** The window switched projects: the tree refers to the previous one's documents. */
|
||||
export function resetHierarchy() {
|
||||
cancelInflight();
|
||||
useHierarchy.setState((s) => ({
|
||||
root: null,
|
||||
children: new Map(),
|
||||
failed: new Set(),
|
||||
notes: new Map(),
|
||||
loading: false,
|
||||
generation: s.generation + 1,
|
||||
}));
|
||||
@@ -74,12 +88,18 @@ export function hierarchyTitle(mode: HierarchyMode, direction: HierarchyDirectio
|
||||
return direction === "incoming" ? tx`Supertypes of ${name}` : tx`Subtypes of ${name}`;
|
||||
}
|
||||
|
||||
function request(mode: HierarchyMode, doc: number, line: number, col: number, direction: HierarchyDirection) {
|
||||
return lspRequest(() =>
|
||||
mode === "call"
|
||||
? commands.lspCallHierarchy(doc, { line, col }, direction)
|
||||
: commands.lspTypeHierarchy(doc, { line, col }, direction),
|
||||
);
|
||||
async function request(mode: HierarchyMode, doc: number, line: number, col: number, direction: HierarchyDirection) {
|
||||
if (mode === "type") {
|
||||
const items = await lspRequest(() => commands.lspTypeHierarchy(doc, { line, col }, direction));
|
||||
return { items, incomplete: null };
|
||||
}
|
||||
const id = nextRequestId();
|
||||
inflight.add(id);
|
||||
try {
|
||||
return await lspRequest(() => commands.lspCallHierarchy(id, doc, { line, col }, direction));
|
||||
} finally {
|
||||
inflight.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function childId(parent: string, item: HierarchyItem): string {
|
||||
@@ -87,7 +107,7 @@ export function childId(parent: string, item: HierarchyItem): string {
|
||||
return `${parent}/${item.path}:${p.line}:${p.col}:${item.name}`;
|
||||
}
|
||||
|
||||
function setChildren(generation: number, parent: string, items: HierarchyItem[]) {
|
||||
function setChildren(generation: number, parent: string, items: HierarchyItem[], note: string | null) {
|
||||
const s = useHierarchy.getState();
|
||||
if (s.generation !== generation) return;
|
||||
const seen = new Set<string>();
|
||||
@@ -98,7 +118,10 @@ function setChildren(generation: number, parent: string, items: HierarchyItem[])
|
||||
seen.add(id);
|
||||
nodes.push({ id, item });
|
||||
}
|
||||
useHierarchy.setState({ children: new Map(s.children).set(parent, nodes) });
|
||||
useHierarchy.setState({
|
||||
children: new Map(s.children).set(parent, nodes),
|
||||
...(note ? { notes: new Map(s.notes).set(parent, note) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
type RootResult = "loaded" | "noTarget" | "failed" | "stale";
|
||||
@@ -109,10 +132,13 @@ async function loadRoot(quiet = false): Promise<RootResult> {
|
||||
const root = s.root;
|
||||
if (!root) return "stale";
|
||||
const generation = s.generation + 1;
|
||||
useHierarchy.setState({ generation, children: new Map(), failed: new Set(), loading: true });
|
||||
cancelInflight();
|
||||
useHierarchy.setState({ generation, children: new Map(), failed: new Set(), notes: new Map(), loading: true });
|
||||
try {
|
||||
const items = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction));
|
||||
setChildren(generation, ROOT_ID, items);
|
||||
const { items, incomplete } = await withDocument(root.path, (doc) =>
|
||||
request(s.mode, doc, root.line, root.col, s.direction),
|
||||
);
|
||||
setChildren(generation, ROOT_ID, items, incomplete && incompleteNote(incomplete, root.name));
|
||||
return "loaded";
|
||||
} catch (e) {
|
||||
if (useHierarchy.getState().generation !== generation) return "stale";
|
||||
@@ -160,8 +186,10 @@ export async function loadChildren(node: HierarchyNode) {
|
||||
const { mode, direction, generation } = useHierarchy.getState();
|
||||
const at = node.item.selectionRange.start;
|
||||
try {
|
||||
const items = await withDocument(node.item.path, (doc) => request(mode, doc, at.line, at.col, direction));
|
||||
setChildren(generation, node.id, items);
|
||||
const { items, incomplete } = await withDocument(node.item.path, (doc) =>
|
||||
request(mode, doc, at.line, at.col, direction),
|
||||
);
|
||||
setChildren(generation, node.id, items, incomplete && incompleteNote(incomplete, node.item.name));
|
||||
} catch (e) {
|
||||
if (useHierarchy.getState().generation !== generation) return;
|
||||
useHierarchy.setState((st) => ({
|
||||
|
||||
@@ -25,7 +25,8 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
|
||||
|
||||
const { _addEditor } = await import("@/editor/api");
|
||||
const { fetchUsages, incompleteNote } = await import("./usages");
|
||||
const { fetchUsages } = await import("./usages");
|
||||
const { incompleteNote } = await import("@/editor/lsp/lsp");
|
||||
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
|
||||
|
||||
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
|
||||
@@ -55,6 +56,9 @@ describe("Find Usages fallback", () => {
|
||||
expect(incompleteNote("tooManyFiles", "x")).toBe(
|
||||
"May be incomplete: too many files mention 'x' to search them all",
|
||||
);
|
||||
expect(incompleteNote("filesSkipped", "x")).toBe(
|
||||
"May be incomplete: some files that mention 'x' are too large or unreadable",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists a certain local's references when the server is not ready", async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
|
||||
import { create } from "zustand";
|
||||
import type { Location, ReferencesIncomplete, RequestId } from "@/bindings";
|
||||
import type { Location, RequestId } from "@/bindings";
|
||||
import { clearLineTokens } from "@/editor/lineTokens";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import {
|
||||
@@ -158,12 +159,6 @@ interface FetchedUsages {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Why the server's answer may miss usages, for the results' note. */
|
||||
export function incompleteNote(reason: ReferencesIncomplete, word: string | null): string {
|
||||
if (reason === "indexing") return "May be incomplete: the language server is still indexing";
|
||||
return `May be incomplete: too many files mention ${word ? `'${word}'` : "the name"} to search them all`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's references across the project (files outside its analysis scope
|
||||
* included); when it is not ready or fails, the syntax fallback (an empty server
|
||||
|
||||
Reference in new issue
Заблокувати користувача