feat(usages): search Java usages in Project Files first, Project and Libraries on demand (#1837)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-06 21:46:29 +08:00
1 джерело ef95cd1716
коміт a6d17543fd
26 files changed
+963 -96

No files matched your search

+10
Переглянути файл
@@ -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;
+22 -2
Переглянути файл
@@ -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}
+13
Переглянути файл
@@ -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;
+20 -2
Переглянути файл
@@ -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 ? (
+8 -2
Переглянути файл
@@ -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(
+77 -2
Переглянути файл
@@ -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,
+159 -25
Переглянути файл
@@ -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;
+152 -3
Переглянути файл
@@ -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(