дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837)
Цей коміт міститься в:
1 джерело
ef95cd1716
коміт
a6d17543fd
26 files changed
+963
-96
No files matched your search
@@ -424,9 +424,12 @@ export const commands = {
|
||||
/**
|
||||
* References of the symbol at `pos`. `project_wide` also searches project files
|
||||
* outside the language server's analysis scope; bulk scans over many symbols
|
||||
* leave it off. Cancel with `cancel_request(request)`.
|
||||
* leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
|
||||
* are not searched; with it, those found in the sources arrive on `on_partial` before
|
||||
* the slower search for the rest, and the result has them all. Cancel with
|
||||
* `cancel_request(request)`.
|
||||
*/
|
||||
lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })),
|
||||
lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean, libraries: boolean, onPartial: Channel<References>) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide, libraries, onPartial })),
|
||||
lspDocumentHighlights: (doc: DocId, pos: Pos) => typedError<Highlight[], IdeError>(__TAURI_INVOKE("lsp_document_highlights", { doc, pos })),
|
||||
lspWorkspaceSymbols: (request: RequestId, query: string) => typedError<WorkspaceSymbol[], IdeError>(__TAURI_INVOKE("lsp_workspace_symbols", { request, query })),
|
||||
lspCodeActions: (doc: DocId, range: Range, only: string[]) => typedError<CodeAction[], IdeError>(__TAURI_INVOKE("lsp_code_actions", { doc, range, only })),
|
||||
@@ -2113,6 +2116,8 @@ export type ClassifiedReference = {
|
||||
access?: ReferenceAccess | null,
|
||||
location: Location,
|
||||
kind: ReferenceKind,
|
||||
/** The usage's line, when the server has it at hand (a library class's). */
|
||||
text?: string | null,
|
||||
};
|
||||
|
||||
export type ClearCachesResult = {
|
||||
@@ -6178,6 +6183,8 @@ export type References = {
|
||||
classified?: ClassifiedReference[],
|
||||
locations: Location[],
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
/** The dependencies and the JDK were not searched (asked for without `libraries`). */
|
||||
librariesSkipped?: boolean,
|
||||
};
|
||||
|
||||
/** Why a references search may have missed usages. */
|
||||
|
||||
@@ -15,6 +15,12 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
});
|
||||
const executeAction = vi.hoisted(() => vi.fn(async () => true));
|
||||
vi.mock("@/platform/actions", () => ({ executeAction }));
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const { lensTargets, usagesLensExtension } = await import("./codeLens");
|
||||
const { lspDoc } = await import("./lsp");
|
||||
@@ -89,7 +95,15 @@ describe("background usage counts", () => {
|
||||
it("uses the server's existing scope and leaves full searches to the explicit action", async () => {
|
||||
const view = editor();
|
||||
await settle();
|
||||
expect(ipc.lspReferences).toHaveBeenCalledWith(expect.any(Number), 7, { line: 0, col: 0 }, false, false);
|
||||
expect(ipc.lspReferences).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
7,
|
||||
{ line: 0, col: 0 },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(titles(view)).toEqual(["2 usages"]);
|
||||
view.state.facet(codeLenses)[0]!.run!(view, new MouseEvent("click"));
|
||||
expect(executeAction).toHaveBeenCalledWith("find.showUsages");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* and symbol, and at most a few reference requests in flight. Beside each
|
||||
* count, the declaration's last author from VCS blame (`authorLens`).
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { codeLenses, type CodeLens } from "@/editor/api";
|
||||
@@ -195,7 +196,15 @@ class UsagesLensPlugin {
|
||||
this.pending.add(request);
|
||||
// Opening candidate files for each count triggers expensive server analysis.
|
||||
// Only an explicit Find Usages should widen the server's scope.
|
||||
return commands.lspReferences(request, doc.doc, { line: t.line, col: t.col }, false, false);
|
||||
return commands.lspReferences(
|
||||
request,
|
||||
doc.doc,
|
||||
{ line: t.line, col: t.col },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
new Channel(),
|
||||
);
|
||||
}, wanted);
|
||||
if (wanted()) {
|
||||
if (incomplete) this.incomplete = true;
|
||||
|
||||
@@ -65,6 +65,12 @@ const showHint = vi.fn();
|
||||
vi.mock("@/editor/lsp/hint", () => ({ showHint }));
|
||||
const executeAction = vi.fn();
|
||||
vi.mock("@/platform/actions", () => ({ executeAction }));
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
let answer = "cancel";
|
||||
const ask = vi.fn(async (_request: MessageRequest) => ({ button: answer, checked: false }));
|
||||
let confirmAnswer: { checked: boolean } | null = { checked: false };
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* Files (project view): references to the file's top-level symbols from other
|
||||
* files, when the language server can answer (`fileUsages`).
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings";
|
||||
@@ -254,7 +255,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom
|
||||
if (target.local)
|
||||
return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
|
||||
const { locations: refs } = await lspRequest(() =>
|
||||
commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true),
|
||||
commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true, false, new Channel()),
|
||||
);
|
||||
return refs.filter((l) => outside(l.path, l.range));
|
||||
}
|
||||
@@ -554,7 +555,15 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal)
|
||||
// Every symbol of every file: the server's own scope (a project search each would be too slow).
|
||||
refs = (
|
||||
await lspRequest(() =>
|
||||
commands.lspReferences(nextRequestId(), doc, item.selection_range.start, false, false),
|
||||
commands.lspReferences(
|
||||
nextRequestId(),
|
||||
doc,
|
||||
item.selection_range.start,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
new Channel(),
|
||||
),
|
||||
)
|
||||
).locations;
|
||||
} catch (e) {
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.scope {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.searching {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -11,15 +11,17 @@ import { Decoration, EditorView } from "@codemirror/view";
|
||||
import { codePreviewExtensions } from "@/editor/api";
|
||||
import { basename } from "@/platform/project";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui";
|
||||
import { Icon, IconButton, List, Popup, Select, Spinner, fileIconName, listNavigate } from "@/ui";
|
||||
import { openLocation, readFileLines } from "./ipc";
|
||||
import {
|
||||
dismissUsagesPopup,
|
||||
setUsagesPopupScope,
|
||||
showUsageResults,
|
||||
usageCount,
|
||||
useUsagesPopup,
|
||||
type Usage,
|
||||
type UsageGroup,
|
||||
type UsageScope,
|
||||
} from "./usages";
|
||||
import { UsagePreview } from "./UsagesView";
|
||||
import styles from "./UsagesPopup.module.css";
|
||||
@@ -79,7 +81,7 @@ export function UsagesPopup() {
|
||||
const ROW = 24;
|
||||
|
||||
function UsagesPopupInner() {
|
||||
const { anchor, symbol, groups, note } = useUsagesPopup();
|
||||
const { anchor, symbol, groups, note, loading, scope } = useUsagesPopup();
|
||||
const preview = useUsagesPopupPreview((s) => s.preview);
|
||||
const all = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -152,6 +154,24 @@ function UsagesPopupInner() {
|
||||
{filter || items.length !== total ? tx`${items.length} of ${total} found` : tx`${total} found`}
|
||||
</span>
|
||||
</span>
|
||||
{scope && (
|
||||
<Select<UsageScope>
|
||||
variant="ghost"
|
||||
aria-label={tx("Scope")}
|
||||
className={styles.scope}
|
||||
value={scope}
|
||||
options={[
|
||||
{ value: "project", label: tx("Project Files") },
|
||||
{ value: "libraries", label: tx("Project and Libraries") },
|
||||
]}
|
||||
onChange={setUsagesPopupScope}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<span className={styles.searching} role="status" aria-label={tx("Searching for more usages")}>
|
||||
<Spinner size={12} />
|
||||
</span>
|
||||
)}
|
||||
{filter && (
|
||||
<span className={clsx(styles.filter, !items.length && styles.noMatch)} role="status">
|
||||
{filter}
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.link {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -66,8 +66,16 @@ export function UsagesTabs() {
|
||||
const tabs = useUsages((s) => s.tabs);
|
||||
const search = useUsages(activeUsageSearch);
|
||||
if (!search) return null;
|
||||
const label = (t: (typeof tabs)[number]) =>
|
||||
t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages");
|
||||
const label = (t: (typeof tabs)[number]) => {
|
||||
if (!t.symbol) return tx("Usages");
|
||||
const text =
|
||||
t.scope === "project"
|
||||
? tx("Usages of '{symbol}' in Project Files")
|
||||
: t.scope === "libraries"
|
||||
? tx("Usages of '{symbol}' in Project and Libraries")
|
||||
: tx("Usages of '{symbol}'");
|
||||
return formatMessage(text, { symbol: t.symbol });
|
||||
};
|
||||
return (
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -268,6 +276,16 @@ export function UsagesView() {
|
||||
</div>
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<UsageAccessFilter items={allUsages} />
|
||||
{!search.loading && !search.error && search.scope === "project" && (
|
||||
<div className={styles.summary}>
|
||||
<T>Usages in Project Files</T>
|
||||
{search.searchLibraries && (
|
||||
<button type="button" className={styles.link} onClick={() => search.searchLibraries?.()}>
|
||||
{tx("Search in Project and Libraries")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(search.loading || search.error) && (
|
||||
<div className={styles.summary} role="status" aria-live="polite">
|
||||
{search.loading ? (
|
||||
|
||||
@@ -23,6 +23,12 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
|
||||
});
|
||||
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const { _addEditor } = await import("@/editor/api");
|
||||
const { fetchUsages } = await import("./usages");
|
||||
@@ -42,7 +48,7 @@ beforeEach(() => {
|
||||
describe("Find Usages fallback", () => {
|
||||
it("uses the server when it answers, even with nothing", async () => {
|
||||
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null }));
|
||||
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null });
|
||||
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null, librariesSkipped: false });
|
||||
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -50,7 +56,7 @@ describe("Find Usages fallback", () => {
|
||||
const usage = { path: "/p/a.ts", range: r(1, 0, 1) };
|
||||
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [usage], incomplete: "indexing" }));
|
||||
const res = await fetchUsages(caret, 42 as never);
|
||||
expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true);
|
||||
expect(cmd.lspReferences).toHaveBeenCalledWith(42, 3, { line: 1, col: 0 }, false, true, false, expect.anything());
|
||||
expect(res.groups[0]!.usages.length).toBe(1);
|
||||
expect(res.note).toBe(incompleteNote("indexing", "x"));
|
||||
expect(incompleteNote("tooManyFiles", "x")).toBe(
|
||||
|
||||
@@ -5,12 +5,33 @@ vi.mock("@/workbench/contributions", async (orig) => ({
|
||||
...(await orig<typeof import("@/workbench/contributions")>()),
|
||||
toolWindowService: () => ({ show }),
|
||||
}));
|
||||
const referenceResponse = vi.fn();
|
||||
const lspReferences = vi.fn(async (..._args: unknown[]) => ({ status: "ok", data: null }));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, lspReferences } };
|
||||
});
|
||||
vi.mock("./ipc", async (orig) => ({
|
||||
...(await orig<typeof import("./ipc")>()),
|
||||
readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]),
|
||||
caretContext: () => ({ doc: 1, path: "/p/a.rs", line: 0, col: 0, word: "used", view: null }),
|
||||
lspRequest: (request: () => unknown) => {
|
||||
request();
|
||||
return referenceResponse();
|
||||
},
|
||||
readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();", " used();"]]]),
|
||||
}));
|
||||
const channels: { onmessage: (refs: unknown) => void }[] = [];
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
constructor() {
|
||||
channels.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { activeUsageSearch, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } =
|
||||
const { activeUsageSearch, findUsages, openUsageTab, pinUsageTab, resetUsages, showUsageResults, useUsages } =
|
||||
await import("./usages");
|
||||
|
||||
describe("showUsageResults", () => {
|
||||
@@ -25,6 +46,19 @@ describe("showUsageResults", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the lines the server gave along for files it does not read", async () => {
|
||||
const lib = {
|
||||
path: "jdt://contents/lib/Lib.class",
|
||||
range: { start: { line: 3, col: 4 }, end: { line: 3, col: 8 } },
|
||||
};
|
||||
await showUsageResults({
|
||||
symbol: "used",
|
||||
locations: [lib],
|
||||
classified: [{ location: lib, kind: "unknown", text: " used();" }],
|
||||
});
|
||||
expect(activeUsageSearch(useUsages.getState())!.groups[0]!.usages[0]!.text).toBe(" used();");
|
||||
});
|
||||
|
||||
it("ignores malformed arguments", async () => {
|
||||
show.mockClear();
|
||||
await showUsageResults({ symbol: "x" });
|
||||
@@ -32,6 +66,47 @@ describe("showUsageResults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUsages", () => {
|
||||
it("searches Project Files, then Project and Libraries on request", async () => {
|
||||
const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
|
||||
lspReferences.mockClear();
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null, librariesSkipped: true });
|
||||
await findUsages();
|
||||
const project = activeUsageSearch(useUsages.getState())!;
|
||||
expect(project).toMatchObject({ loading: false, scope: "project" });
|
||||
expect(lspReferences.mock.lastCall![5]).toBe(false);
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
|
||||
project.searchLibraries!();
|
||||
await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.loading).toBe(false));
|
||||
expect(lspReferences.mock.lastCall![5]).toBe(true);
|
||||
const all = activeUsageSearch(useUsages.getState())!;
|
||||
expect(all.scope).toBe("libraries");
|
||||
// Rerun keeps the scope.
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
|
||||
all.rerun!();
|
||||
await vi.waitFor(() => expect(lspReferences).toHaveBeenCalledTimes(3));
|
||||
expect(lspReferences.mock.lastCall![5]).toBe(true);
|
||||
resetUsages();
|
||||
});
|
||||
|
||||
it("shows the usages found first while it searches for the rest", async () => {
|
||||
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
|
||||
let resolve!: (refs: unknown) => void;
|
||||
referenceResponse.mockReturnValueOnce(new Promise((r) => (resolve = r)));
|
||||
const search = findUsages();
|
||||
await vi.waitFor(() => expect(channels).not.toHaveLength(0));
|
||||
channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null });
|
||||
await vi.waitFor(() => expect(activeUsageSearch(useUsages.getState())!.groups).toHaveLength(1));
|
||||
expect(activeUsageSearch(useUsages.getState())).toMatchObject({ loading: true, error: null });
|
||||
resolve({ locations: [at(1), at(2)], incomplete: null });
|
||||
await search;
|
||||
const done = activeUsageSearch(useUsages.getState())!;
|
||||
expect(done.loading).toBe(false);
|
||||
expect(done.groups[0]!.usages.map((u) => u.line)).toEqual([1, 2]);
|
||||
resetUsages();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage tabs", () => {
|
||||
const search = (id: number) => ({
|
||||
id,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
|
||||
import { create } from "zustand";
|
||||
import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, RequestId } from "@/bindings";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type { ClassifiedReference, ReferenceAccess, ReferenceKind, Location, References, RequestId } from "@/bindings";
|
||||
import { clearLineTokens } from "@/editor/lineTokens";
|
||||
import { incompleteNote } from "@/editor/lsp/lsp";
|
||||
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import {
|
||||
caretContext,
|
||||
@@ -49,7 +51,7 @@ export function groupUsages(
|
||||
const key = usageLocationKey(l);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const text = files.get(l.path)?.[l.range.start.line] ?? "";
|
||||
const text = files.get(l.path)?.[l.range.start.line] ?? kinds.get(key)?.text ?? "";
|
||||
const endCol = l.range.end.line === l.range.start.line ? l.range.end.col : Math.max(text.length, l.range.start.col);
|
||||
let list = byPath.get(l.path);
|
||||
if (!list) byPath.set(l.path, (list = []));
|
||||
@@ -86,6 +88,15 @@ export function trimPreview(u: Usage): { text: string; textCol: number; from: nu
|
||||
return { text, textCol: lead, from, to };
|
||||
}
|
||||
|
||||
/** Project Files, or Project and Libraries (Java's dependency jars and JDK too). */
|
||||
export type UsageScope = "project" | "libraries";
|
||||
|
||||
/** The scope a search ran in, when there was a choice (`null` when the server has none). */
|
||||
function searchedScope(libraries: boolean, librariesSkipped: boolean, widenable: boolean): UsageScope | null {
|
||||
if (librariesSkipped) return "project";
|
||||
return libraries && widenable ? "libraries" : null;
|
||||
}
|
||||
|
||||
export interface UsageSearch {
|
||||
/** Identifies one search run (UI state such as expansion resets per run). */
|
||||
id: number;
|
||||
@@ -97,6 +108,10 @@ export interface UsageSearch {
|
||||
note: string | null;
|
||||
/** Re-runs the same query (rerun button). */
|
||||
rerun: (() => void) | null;
|
||||
/** Where it searched, when the server can search beyond Project Files (Java's jars and JDK). */
|
||||
scope?: UsageScope | null;
|
||||
/** Runs it again in Project and Libraries. */
|
||||
searchLibraries?: (() => void) | null;
|
||||
/** Kept when a new search runs (Pin Tab). */
|
||||
pinned?: boolean;
|
||||
}
|
||||
@@ -163,6 +178,10 @@ interface UsagesPopupState {
|
||||
symbol: string | null;
|
||||
groups: UsageGroup[];
|
||||
note: string | null;
|
||||
/** Shows those found so far while the search goes on. */
|
||||
loading: boolean;
|
||||
/** Where it searched, when the server can search beyond Project Files. */
|
||||
scope: UsageScope | null;
|
||||
}
|
||||
|
||||
export const useUsagesPopup = create<UsagesPopupState>(() => ({
|
||||
@@ -171,31 +190,64 @@ export const useUsagesPopup = create<UsagesPopupState>(() => ({
|
||||
symbol: null,
|
||||
groups: [],
|
||||
note: null,
|
||||
loading: false,
|
||||
scope: null,
|
||||
}));
|
||||
|
||||
interface FetchedUsages {
|
||||
groups: UsageGroup[];
|
||||
note: string | null;
|
||||
librariesSkipped: boolean;
|
||||
}
|
||||
|
||||
/** Paths whose lines are read to show these usages: not those the server gave along. */
|
||||
function pathsToRead(locs: readonly Location[], classified: readonly ClassifiedReference[]): string[] {
|
||||
const given = new Set(classified.filter((r) => r.text != null).map((r) => usageLocationKey(r.location)));
|
||||
return locs.filter((l) => !given.has(usageLocationKey(l))).map((l) => l.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* answer is respected). Without a fallback the server error stands.
|
||||
* answer is respected). Without a fallback the server error stands. Without
|
||||
* `libraries`, Java's dependencies and JDK are not searched (Project Files); with it,
|
||||
* those found in the sources go to `onPartial` before the slower search for the rest.
|
||||
* Cancel with `commands.cancelRequest(request)`.
|
||||
*/
|
||||
export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise<FetchedUsages> {
|
||||
export async function fetchUsages(
|
||||
c: CaretContext,
|
||||
request: RequestId = nextRequestId(),
|
||||
onPartial?: (found: FetchedUsages) => void,
|
||||
libraries = false,
|
||||
): Promise<FetchedUsages> {
|
||||
// Usage lines are read afresh: so are the files their syntax colours come from.
|
||||
clearLineTokens();
|
||||
let locs: Location[];
|
||||
let classified: ClassifiedReference[] = [];
|
||||
let note: string | null = null;
|
||||
let librariesSkipped = false;
|
||||
let settled = false;
|
||||
const channel = new Channel<References>();
|
||||
channel.onmessage = (refs) => {
|
||||
if (!onPartial) return;
|
||||
void readManyFileLines(pathsToRead(refs.locations, refs.classified ?? [])).then((files) => {
|
||||
if (settled) return;
|
||||
onPartial({
|
||||
groups: groupUsages(refs.locations, files, refs.classified ?? []),
|
||||
note: refs.incomplete ? incompleteNote(refs.incomplete, c.word) : null,
|
||||
librariesSkipped: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
try {
|
||||
const refs = await lspRequest(() =>
|
||||
commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true),
|
||||
);
|
||||
commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true, libraries, channel),
|
||||
).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
locs = refs.locations;
|
||||
classified = refs.classified ?? [];
|
||||
librariesSkipped = refs.librariesSkipped ?? false;
|
||||
if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word);
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) throw e;
|
||||
@@ -210,8 +262,8 @@ export async function fetchUsages(c: CaretContext, request: RequestId = nextRequ
|
||||
locs = fb.locs;
|
||||
note = fb.note;
|
||||
}
|
||||
const files = await readManyFileLines(locs.map((l) => l.path));
|
||||
return { groups: groupUsages(locs, files, classified), note };
|
||||
const files = await readManyFileLines(pathsToRead(locs, classified));
|
||||
return { groups: groupUsages(locs, files, classified), note, librariesSkipped };
|
||||
}
|
||||
|
||||
/** In-flight server requests: of tool window searches by search id, and of the popup. */
|
||||
@@ -231,7 +283,8 @@ export function resetUsages() {
|
||||
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
|
||||
popupRequest = null;
|
||||
useUsages.setState({ tabs: [], active: null });
|
||||
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null });
|
||||
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null, loading: false, scope: null });
|
||||
popupTarget = null;
|
||||
}
|
||||
|
||||
export async function findUsages() {
|
||||
@@ -240,9 +293,15 @@ export async function findUsages() {
|
||||
hint("Open a file to find usages");
|
||||
return;
|
||||
}
|
||||
const run = async (prev: number | null) => {
|
||||
// `widenable`: an earlier run of this search skipped the libraries.
|
||||
const run = async (prev: number | null, libraries: boolean, widenable: boolean) => {
|
||||
const id = ++searchSeq;
|
||||
const base = { id, symbol: c.word, rerun: () => void run(id) };
|
||||
const base = {
|
||||
id,
|
||||
symbol: c.word,
|
||||
rerun: () => void run(id, libraries, widenable),
|
||||
searchLibraries: () => void run(id, true, true),
|
||||
};
|
||||
const loading = { ...base, loading: true, groups: [], error: null, note: null };
|
||||
if (prev !== null) cancelSearch(prev);
|
||||
if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading);
|
||||
@@ -250,8 +309,16 @@ export async function findUsages() {
|
||||
const request = nextRequestId();
|
||||
inflight.set(id, request);
|
||||
try {
|
||||
const { groups, note } = await fetchUsages(c, request);
|
||||
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note });
|
||||
const { groups, note, librariesSkipped } = await fetchUsages(
|
||||
c,
|
||||
request,
|
||||
(found) => {
|
||||
if (inflight.get(id) === request) replaceUsageTab(id, { ...loading, groups: found.groups, note: found.note });
|
||||
},
|
||||
libraries,
|
||||
);
|
||||
const scope = searchedScope(libraries, librariesSkipped, widenable);
|
||||
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note, scope });
|
||||
} catch (e) {
|
||||
// Cancelled here (closed tab, rerun): a newer state already replaced it.
|
||||
if (isCancelled(e) && !inflight.has(id)) return;
|
||||
@@ -262,7 +329,7 @@ export async function findUsages() {
|
||||
if (inflight.get(id) === request) inflight.delete(id);
|
||||
}
|
||||
};
|
||||
await run(null);
|
||||
await run(null, false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,34 +367,101 @@ export function dismissUsagesPopup() {
|
||||
useUsagesPopup.setState({ open: false });
|
||||
}
|
||||
|
||||
/** How long Show Usages waits for all usages before it shows those found first. */
|
||||
const EARLY_POPUP_DELAY_MS = 300;
|
||||
/** How long the hint after Show Usages stays (and invoking it again widens its search). */
|
||||
const HINT_MS = 4000;
|
||||
|
||||
/**
|
||||
* What the popup, or the hint after it, shows usages of: invoked again meanwhile, Show
|
||||
* Usages searches it in Project and Libraries, wherever the caret is now.
|
||||
*/
|
||||
let popupTarget: { c: CaretContext; widenable: boolean; hintUntil: number } | null = null;
|
||||
|
||||
export async function showUsagesPopup() {
|
||||
const target = popupTarget;
|
||||
const popup = useUsagesPopup.getState();
|
||||
if (target?.widenable && ((popup.open && popup.scope === "project") || Date.now() < target.hintUntil)) {
|
||||
await searchPopup(target.c, true, true);
|
||||
return;
|
||||
}
|
||||
const c = caretContext();
|
||||
if (!c) {
|
||||
hint("Open a file to show usages");
|
||||
return;
|
||||
}
|
||||
await searchPopup(c, false, false);
|
||||
}
|
||||
|
||||
/** The popup's scope chooser: searches its symbol again in `scope`. */
|
||||
export function setUsagesPopupScope(scope: UsageScope) {
|
||||
const target = popupTarget;
|
||||
if (!target || scope === useUsagesPopup.getState().scope) return;
|
||||
void searchPopup(target.c, scope === "libraries", target.widenable);
|
||||
}
|
||||
|
||||
async function searchPopup(c: CaretContext, libraries: boolean, widenable: boolean) {
|
||||
// Set from the start: the scope chooser can switch while this search goes on.
|
||||
popupTarget = { c, widenable, hintUntil: 0 };
|
||||
const reopened = useUsagesPopup.getState().open;
|
||||
if (reopened) useUsagesPopup.setState({ loading: true, scope: libraries ? "libraries" : "project" });
|
||||
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
|
||||
const request = nextRequestId();
|
||||
popupRequest = request;
|
||||
// Opened with those found first when the rest take a while; it then stays open, even
|
||||
// when they were all. A quick search still jumps to a single usage.
|
||||
let early = false;
|
||||
let found: FetchedUsages | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const openEarly = () => {
|
||||
if (popupRequest !== request || !found) return;
|
||||
if (!early && !reopened) useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word });
|
||||
early = true;
|
||||
useUsagesPopup.setState({ ...found, loading: true, scope: widenable ? "libraries" : null });
|
||||
};
|
||||
const again = shortcutLabel("find.showUsages");
|
||||
try {
|
||||
const { groups, note } = await fetchUsages(c, request);
|
||||
const { groups, note, librariesSkipped } = await fetchUsages(
|
||||
c,
|
||||
request,
|
||||
(partial) => {
|
||||
if (!usageCount(partial.groups)) return;
|
||||
found = partial;
|
||||
if (early) openEarly();
|
||||
else timer ??= setTimeout(openEarly, EARLY_POPUP_DELAY_MS);
|
||||
},
|
||||
libraries,
|
||||
).finally(() => clearTimeout(timer));
|
||||
if (popupRequest !== request) return;
|
||||
const scope = searchedScope(libraries, librariesSkipped, widenable);
|
||||
popupTarget = { c, widenable: widenable || scope !== null, hintUntil: 0 };
|
||||
const total = usageCount(groups);
|
||||
if (early || reopened || total > 1) {
|
||||
if (!early && !reopened) useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word });
|
||||
useUsagesPopup.setState({ groups, note, loading: false, scope });
|
||||
return;
|
||||
}
|
||||
// As in IDEA, the hint offers to widen the search, which Show Usages does while it shows.
|
||||
const widen = scope === "project" && again !== "";
|
||||
if (widen) popupTarget.hintUntil = Date.now() + HINT_MS;
|
||||
if (!total) {
|
||||
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
|
||||
hint(note ? `${none}. ${note}` : none);
|
||||
if (widen) hint(`No usages found in Project Files. Press ${again} again to search in 'Project and Libraries'`);
|
||||
else if (scope === "project") hint("No usages found in Project Files");
|
||||
else {
|
||||
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
|
||||
hint(note ? `${none}. ${note}` : none);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (total === 1) {
|
||||
// IDEA jumps to the only usage and says so in the target editor.
|
||||
const u = groups[0]!.usages[0]!;
|
||||
await openLocation(u.path, u.line, u.col);
|
||||
hint("Only one usage in Project Files");
|
||||
return;
|
||||
}
|
||||
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
|
||||
// IDEA jumps to the only usage and says so in the target editor.
|
||||
const u = groups[0]!.usages[0]!;
|
||||
await openLocation(u.path, u.line, u.col);
|
||||
if (widen) hint(`Only one usage in Project Files. Press ${again} again to search in 'Project and Libraries'`);
|
||||
else if (scope === "libraries") hint("Only one usage in Project and Libraries");
|
||||
else hint("Only one usage in Project Files");
|
||||
} catch (e) {
|
||||
if (popupRequest !== request || isCancelled(e)) return;
|
||||
if (early || reopened) useUsagesPopup.setState({ loading: false });
|
||||
reportError(e);
|
||||
} finally {
|
||||
if (popupRequest === request) popupRequest = null;
|
||||
|
||||
@@ -2,16 +2,44 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const calls: string[] = [];
|
||||
const referenceResponse = vi.fn(async () => ({ locations: [loc], incomplete: null }));
|
||||
const referenceResponse = vi.fn(
|
||||
async (): Promise<{ locations: (typeof loc)[]; incomplete: null; librariesSkipped?: boolean }> => ({
|
||||
locations: [loc],
|
||||
incomplete: null,
|
||||
}),
|
||||
);
|
||||
const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
|
||||
const lspReferences = vi.fn(async () => ({ status: "ok", data: null }));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, lspReferences } };
|
||||
});
|
||||
vi.mock("@/platform/keymap", async (orig) => ({
|
||||
...(await orig<typeof import("@/platform/keymap")>()),
|
||||
shortcutLabel: (id: string) => (id === "find.showUsages" ? "⌥⌘F7" : ""),
|
||||
}));
|
||||
vi.mock("./ipc", async (orig) => ({
|
||||
...(await orig<typeof import("./ipc")>()),
|
||||
caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }),
|
||||
lspRequest: () => referenceResponse(),
|
||||
lspRequest: (request: () => unknown) => {
|
||||
request();
|
||||
return referenceResponse();
|
||||
},
|
||||
readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]),
|
||||
openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`),
|
||||
hint: (text: string) => void calls.push(`hint ${text}`),
|
||||
}));
|
||||
/** The partial-results channels of the searches, newest last. */
|
||||
const channels: { onmessage: (refs: unknown) => void }[] = [];
|
||||
vi.mock("@tauri-apps/api/core", async (orig) => ({
|
||||
...(await orig<object>()),
|
||||
Channel: class {
|
||||
onmessage = () => {};
|
||||
constructor() {
|
||||
channels.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
const showUsageResults = vi.fn();
|
||||
vi.mock("./usages", async (orig) => ({
|
||||
...(await orig<typeof import("./usages")>()),
|
||||
@@ -19,7 +47,7 @@ vi.mock("./usages", async (orig) => ({
|
||||
}));
|
||||
|
||||
const { UsagesPopup, filterUsages, usageLocations } = await import("./UsagesPopup");
|
||||
const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup } = await import("./usages");
|
||||
const { showUsagesPopup, useUsagesPopup, dismissUsagesPopup, setUsagesPopupScope } = await import("./usages");
|
||||
|
||||
const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text });
|
||||
const groups = [
|
||||
@@ -198,6 +226,127 @@ it("keeps filter controls available when all classified rows are hidden", () =>
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("opens with the usages found first and fills in the rest", async () => {
|
||||
render(<UsagesPopup />);
|
||||
const at = (line: number) => ({ ...loc, range: { start: { line, col: 4 }, end: { line, col: 8 } } });
|
||||
let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
|
||||
referenceResponse.mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
const before = calls.length;
|
||||
const pending = showUsagesPopup();
|
||||
await act(async () => channels.at(-1)!.onmessage({ locations: [at(1)], incomplete: null }));
|
||||
// Not before the rest had a moment to arrive.
|
||||
expect(useUsagesPopup.getState().open).toBe(false);
|
||||
await act(() => new Promise((r) => setTimeout(r, 350)));
|
||||
expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: true });
|
||||
expect(screen.getByRole("status", { name: "Searching for more usages" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
await act(async () => channels.at(-1)!.onmessage({ locations: [at(1), at(2)], incomplete: null }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
await act(async () => {
|
||||
resolve({ locations: [at(1), at(2), at(3)], incomplete: null });
|
||||
await pending;
|
||||
});
|
||||
expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: false });
|
||||
expect(screen.queryByRole("status", { name: "Searching for more usages" })).toBeNull();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||
expect(calls.slice(before)).toEqual([]);
|
||||
act(() => dismissUsagesPopup());
|
||||
});
|
||||
|
||||
it("jumps to a single usage when all arrive quickly, and otherwise stays open", async () => {
|
||||
render(<UsagesPopup />);
|
||||
let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
|
||||
const respond = () =>
|
||||
referenceResponse.mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
respond();
|
||||
let before = calls.length;
|
||||
let pending = showUsagesPopup();
|
||||
await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null }));
|
||||
await act(async () => {
|
||||
resolve({ locations: [loc], incomplete: null });
|
||||
await pending;
|
||||
});
|
||||
expect(useUsagesPopup.getState().open).toBe(false);
|
||||
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]);
|
||||
respond();
|
||||
before = calls.length;
|
||||
pending = showUsagesPopup();
|
||||
await act(async () => channels.at(-1)!.onmessage({ locations: [loc], incomplete: null }));
|
||||
await act(() => new Promise((r) => setTimeout(r, 350)));
|
||||
await act(async () => {
|
||||
resolve({ locations: [loc], incomplete: null });
|
||||
await pending;
|
||||
});
|
||||
expect(useUsagesPopup.getState()).toMatchObject({ open: true, loading: false });
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
expect(calls.slice(before)).toEqual([]);
|
||||
act(() => dismissUsagesPopup());
|
||||
});
|
||||
|
||||
it("searches Project Files, and Project and Libraries when invoked again", async () => {
|
||||
render(<UsagesPopup />);
|
||||
const at = (line: number) => ({ ...loc, range: { start: { line, col: 4 }, end: { line, col: 8 } } });
|
||||
const libraries = () => (lspReferences.mock.lastCall as unknown[])[5];
|
||||
const widen = "Press ⌥⌘F7 again to search in 'Project and Libraries'";
|
||||
// Nothing in Project Files: the hint offers the libraries, searched when invoked again meanwhile.
|
||||
let before = calls.length;
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [], incomplete: null, librariesSkipped: true });
|
||||
await showUsagesPopup();
|
||||
expect(libraries()).toBe(false);
|
||||
expect(calls.slice(before)).toEqual([`hint No usages found in Project Files. ${widen}`]);
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null });
|
||||
await act(() => showUsagesPopup());
|
||||
expect(libraries()).toBe(true);
|
||||
expect(useUsagesPopup.getState()).toMatchObject({ open: true, scope: "libraries" });
|
||||
expect(screen.getByRole("button", { name: "Scope" })).toHaveTextContent("Project and Libraries");
|
||||
// The scope chooser searches Project Files again.
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null, librariesSkipped: true });
|
||||
await act(async () => setUsagesPopupScope("project"));
|
||||
await vi.waitFor(() => expect(useUsagesPopup.getState()).toMatchObject({ loading: false, scope: "project" }));
|
||||
expect(libraries()).toBe(false);
|
||||
expect(screen.getByRole("button", { name: "Scope" })).toHaveTextContent("Project Files");
|
||||
// Switched while Project and Libraries are still searched: that search gives way.
|
||||
let finish!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
|
||||
referenceResponse.mockReturnValueOnce(new Promise((r) => (finish = r)));
|
||||
await act(async () => setUsagesPopupScope("libraries"));
|
||||
expect(libraries()).toBe(true);
|
||||
expect(useUsagesPopup.getState()).toMatchObject({ loading: true, scope: "libraries" });
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2)], incomplete: null, librariesSkipped: true });
|
||||
await act(async () => setUsagesPopupScope("project"));
|
||||
await vi.waitFor(() => expect(useUsagesPopup.getState()).toMatchObject({ loading: false, scope: "project" }));
|
||||
expect(libraries()).toBe(false);
|
||||
await act(async () => finish({ locations: [at(1), at(2), at(3), at(4)], incomplete: null }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
// Invoked again while it shows Project Files: Project and Libraries.
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1), at(2), at(3)], incomplete: null });
|
||||
await act(() => showUsagesPopup());
|
||||
expect(libraries()).toBe(true);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(3);
|
||||
act(() => dismissUsagesPopup());
|
||||
// The only usage in Project Files is opened; the hint offers the libraries too.
|
||||
before = calls.length;
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null, librariesSkipped: true });
|
||||
await showUsagesPopup();
|
||||
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", `hint Only one usage in Project Files. ${widen}`]);
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null });
|
||||
before = calls.length;
|
||||
await showUsagesPopup();
|
||||
expect(libraries()).toBe(true);
|
||||
expect(calls.slice(before)).toEqual(["open /p/b.ts:1", "hint Only one usage in Project and Libraries"]);
|
||||
// Without a hint showing, it searches the symbol at the caret in Project Files again.
|
||||
referenceResponse.mockResolvedValueOnce({ locations: [at(1)], incomplete: null, librariesSkipped: true });
|
||||
await showUsagesPopup();
|
||||
expect(libraries()).toBe(false);
|
||||
});
|
||||
|
||||
it("discards a pending response after the current popup is dismissed", async () => {
|
||||
let resolve!: (value: { locations: (typeof loc)[]; incomplete: null }) => void;
|
||||
referenceResponse.mockReturnValueOnce(
|
||||
|
||||
@@ -1431,6 +1431,10 @@
|
||||
"No structure to show": "没有可显示的结构",
|
||||
"No tools match.": "没有匹配的工具。",
|
||||
"No usages found": "未找到用法",
|
||||
"No usages found in Project Files": "在项目文件中未找到用法",
|
||||
"No usages found in Project Files. Press {again} again to search in 'Project and Libraries'": "在项目文件中未找到用法。再按一次 {again} 在“项目和库”中搜索",
|
||||
"Search in Project and Libraries": "在项目和库中搜索",
|
||||
"Usages in Project Files": "项目文件中的用法",
|
||||
"No usages of '{name}' found": "未找到 '{name}' 的用法",
|
||||
"Node interpreter": "Node 解释器",
|
||||
"None": "无",
|
||||
@@ -1458,6 +1462,11 @@
|
||||
"Older output is discarded": "较早的输出将被丢弃",
|
||||
"Only in comments": "仅在注释中",
|
||||
"Only one usage in Project Files": "项目文件中只有一个用法",
|
||||
"Only one usage in Project and Libraries": "项目和库中只有一个用法",
|
||||
"Only one usage in Project Files. Press {again} again to search in 'Project and Libraries'": "项目文件中只有一个用法。再按一次 {again} 在“项目和库”中搜索",
|
||||
"Project and Libraries": "项目和库",
|
||||
"Usages of '{symbol}' in Project Files": "'{symbol}' 在项目文件中的用法",
|
||||
"Usages of '{symbol}' in Project and Libraries": "'{symbol}' 在项目和库中的用法",
|
||||
"Open": "打开",
|
||||
"Open Diff": "打开差异",
|
||||
"Open File": "打开文件",
|
||||
@@ -2018,6 +2027,7 @@
|
||||
"Searches": "搜索",
|
||||
"Searching": "正在搜索",
|
||||
"Searching for usages": "正在搜索用法",
|
||||
"Searching for more usages": "正在搜索更多用法",
|
||||
"Second stroke": "第二次击键",
|
||||
"Second stroke:": "第二次击键:",
|
||||
"Select All": "全选",
|
||||
|
||||
Reference in new issue
Заблокувати користувача