дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(lsp): Find usages in unopened files outside the language server's scope (#89)
Reference-based features only asked the language server, which analyses its configured scope (e.g. Pyright's `include`) plus open files, so usages in unopened files outside that scope were silently missing. Find Usages, Show Usages, usage counts, Safe Delete of a symbol, Rename and incoming calls now also search project files that contain the symbol's name (whole word, same language, .gitignore/excludes respected, unsaved edits included, up to 300 files, cancellable). Candidates the first answer didn't cover are opened in the server temporarily, queried and closed again. Files the user has open are never touched, and diagnostics for the temporary files are dropped. An empty result while the server is still indexing is retried once indexing ends. When results may still be incomplete (indexing, file cap), the usages UI says so. `lsp_references` takes a RequestId and is cancelled by newer searches. Not covered yet: the Safe Delete scan when deleting a file or directory. Fixes #83 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
1 джерело
482f391fd1
коміт
8ec35c94ec
17 files changed
+1116
-97
No files matched your search
+18
-1
@@ -287,7 +287,12 @@ export const commands = {
|
||||
* language server returned a non-`file:` URI for. Cached per project.
|
||||
*/
|
||||
lspVirtualDocument: (uri: string) => typedError<VirtualDocument, IdeError>(__TAURI_INVOKE("lsp_virtual_document", { uri })),
|
||||
lspReferences: (doc: DocId, pos: Pos, includeDeclaration: boolean) => typedError<Location[], IdeError>(__TAURI_INVOKE("lsp_references", { doc, pos, includeDeclaration })),
|
||||
/**
|
||||
* 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)`.
|
||||
*/
|
||||
lspReferences: (request: RequestId, doc: DocId, pos: Pos, includeDeclaration: boolean, projectWide: boolean) => typedError<References, IdeError>(__TAURI_INVOKE("lsp_references", { request, doc, pos, includeDeclaration, projectWide })),
|
||||
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 })),
|
||||
@@ -4175,6 +4180,18 @@ export type RecentProject = {
|
||||
branch: string | null,
|
||||
};
|
||||
|
||||
export type References = {
|
||||
locations: Location[],
|
||||
incomplete: ReferencesIncomplete | null,
|
||||
};
|
||||
|
||||
/** Why a references search may have missed usages. */
|
||||
export type ReferencesIncomplete =
|
||||
/** A language server was still indexing. */
|
||||
"indexing" |
|
||||
/** More project files mention the name than were checked. */
|
||||
"tooManyFiles";
|
||||
|
||||
export type Remote = {
|
||||
name: string,
|
||||
/** Fetch URL. */
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type
|
||||
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import { codeLenses, type CodeLens } from "@/editor/api";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { commands, type OutlineItem, type SymbolKind } from "@/platform/ipc";
|
||||
import { commands, nextRequestId, type OutlineItem, type SymbolKind } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { debounce, docOf, isUnavailable, lspRequest } from "./lsp";
|
||||
import { posToOffset } from "./positions";
|
||||
@@ -166,7 +166,9 @@ class UsagesLensPlugin {
|
||||
await Promise.all(
|
||||
todo.slice(i, i + CONCURRENCY).map(async (t) => {
|
||||
try {
|
||||
const refs = await lspRequest(() => commands.lspReferences(doc.doc, { line: t.line, col: t.col }, false));
|
||||
const { locations: refs } = await lspRequest(() =>
|
||||
commands.lspReferences(nextRequestId(), doc.doc, { line: t.line, col: t.col }, false, true),
|
||||
);
|
||||
this.cache.set(key(t), refs.length);
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) unavailable = true;
|
||||
|
||||
@@ -16,7 +16,10 @@ let outline: Answer<OutlineItem[]> = ok([]);
|
||||
let references: Answer<Location[]> = ok([]);
|
||||
const lspGoto = vi.fn(async () => goto());
|
||||
const docOutline = vi.fn(async () => outline());
|
||||
const lspReferences = vi.fn(async () => references());
|
||||
const lspReferences = vi.fn(async () => {
|
||||
const r = references();
|
||||
return r.status === "ok" ? { status: "ok" as const, data: { locations: r.data, incomplete: null } } : r;
|
||||
});
|
||||
const lspCodeActions = vi.fn(async () => ({ status: "ok", data: [] }));
|
||||
const lspApplyCodeAction = vi.fn(async () => ({ status: "ok", data: null }));
|
||||
const docOpen = vi.fn(async (path: string) => ({ status: "ok", data: { id: 99, path } }));
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { LocalSymbol, Location, OutlineItem, Pos, Range, SyntaxNode } from "@/bindings";
|
||||
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { basename } from "@/platform/project";
|
||||
import { editorsFor, whenSynced, type EditorDocContext } from "@/editor/api";
|
||||
@@ -252,7 +252,7 @@ async function findUsages(ctx: EditorDocContext, target: SafeDeleteTarget): Prom
|
||||
comparePos(range.start, target.range.end) > 0;
|
||||
if (target.local)
|
||||
return target.local.references.filter((r) => outside(ctx.path, r)).map((range) => ({ path: ctx.path, range }));
|
||||
const refs = await lspRequest(() => commands.lspReferences(ctx.doc, target.at, false));
|
||||
const { locations: refs } = await lspRequest(() => commands.lspReferences(nextRequestId(), ctx.doc, target.at, false, true));
|
||||
return refs.filter((l) => outside(l.path, l.range));
|
||||
}
|
||||
|
||||
@@ -517,7 +517,8 @@ export async function fileUsages(paths: readonly string[], signal?: AbortSignal)
|
||||
if (signal?.aborted) return;
|
||||
let refs: Location[];
|
||||
try {
|
||||
refs = await lspRequest(() => commands.lspReferences(doc, item.selection_range.start, false));
|
||||
// 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))).locations;
|
||||
} catch (e) {
|
||||
if (isUnavailable(e)) {
|
||||
reason = errorText(e);
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock("@/platform/ipc", async (orig) => {
|
||||
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
|
||||
|
||||
const { _addEditor } = await import("@/editor/api");
|
||||
const { fetchUsages } = await import("./usages");
|
||||
const { fetchUsages, incompleteNote } = await import("./usages");
|
||||
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
|
||||
|
||||
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
|
||||
@@ -39,11 +39,21 @@ beforeEach(() => {
|
||||
|
||||
describe("Find Usages fallback", () => {
|
||||
it("uses the server when it answers, even with nothing", async () => {
|
||||
cmd.lspReferences.mockReturnValueOnce(ok([]));
|
||||
cmd.lspReferences.mockReturnValueOnce(ok({ locations: [], incomplete: null }));
|
||||
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null });
|
||||
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("searches project-wide under the given request and says when results may be incomplete", async () => {
|
||||
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(res.groups[0]!.usages.length).toBe(1);
|
||||
expect(res.note).toBe(incompleteNote("indexing", "x"));
|
||||
expect(incompleteNote("tooManyFiles", "x")).toBe("May be incomplete: too many files mention 'x' to search them all");
|
||||
});
|
||||
|
||||
it("lists a certain local's references when the server is not ready", async () => {
|
||||
cmd.syntaxLocals.mockReturnValueOnce(
|
||||
ok({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
|
||||
import { create } from "zustand";
|
||||
import type { Location } from "@/bindings";
|
||||
import { commands, isCancelled } from "@/platform/ipc";
|
||||
import type { Location, ReferencesIncomplete, RequestId } from "@/bindings";
|
||||
import { commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { toolWindowService } from "@/workbench/contributions";
|
||||
import {
|
||||
caretContext,
|
||||
@@ -119,6 +119,7 @@ export function pinUsageTab(id: number, pinned: boolean) {
|
||||
}
|
||||
|
||||
export function closeUsageTab(id: number) {
|
||||
cancelSearch(id);
|
||||
useUsages.setState((s) => {
|
||||
const i = s.tabs.findIndex((t) => t.id === id);
|
||||
const tabs = s.tabs.filter((t) => t.id !== id);
|
||||
@@ -153,15 +154,25 @@ 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; when it is not ready or fails, the syntax fallback
|
||||
* (an empty server answer is respected). Without a fallback the server error stands.
|
||||
* 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.
|
||||
* Cancel with `commands.cancelRequest(request)`.
|
||||
*/
|
||||
export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
|
||||
export async function fetchUsages(c: CaretContext, request: RequestId = nextRequestId()): Promise<FetchedUsages> {
|
||||
let locs: Location[];
|
||||
let note: string | null = null;
|
||||
try {
|
||||
locs = await lspRequest(() => commands.lspReferences(c.doc, { line: c.line, col: c.col }, false));
|
||||
const refs = await lspRequest(() => commands.lspReferences(request, c.doc, { line: c.line, col: c.col }, false, true));
|
||||
locs = refs.locations;
|
||||
if (refs.incomplete) note = incompleteNote(refs.incomplete, c.word);
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) throw e;
|
||||
let fb = null;
|
||||
@@ -179,8 +190,22 @@ export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
|
||||
return { groups: groupUsages(locs, files), note };
|
||||
}
|
||||
|
||||
/** In-flight server requests: of tool window searches by search id, and of the popup. */
|
||||
const inflight = new Map<number, RequestId>();
|
||||
let popupRequest: RequestId | null = null;
|
||||
|
||||
function cancelSearch(id: number) {
|
||||
const request = inflight.get(id);
|
||||
if (request === undefined) return;
|
||||
inflight.delete(id);
|
||||
void commands.cancelRequest(request).catch(() => {});
|
||||
}
|
||||
|
||||
/** The window switched projects: results (and in-flight searches) of the previous one are dropped. */
|
||||
export function resetUsages() {
|
||||
for (const id of [...inflight.keys()]) cancelSearch(id);
|
||||
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
|
||||
popupRequest = null;
|
||||
useUsages.setState({ tabs: [], active: null });
|
||||
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null });
|
||||
}
|
||||
@@ -195,15 +220,22 @@ export async function findUsages() {
|
||||
const id = ++searchSeq;
|
||||
const base = { id, symbol: c.word, rerun: () => void run(id) };
|
||||
const loading = { ...base, loading: true, groups: [], error: null, note: null };
|
||||
if (prev !== null) cancelSearch(prev);
|
||||
if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading);
|
||||
toolWindowService().show("usages");
|
||||
const request = nextRequestId();
|
||||
inflight.set(id, request);
|
||||
try {
|
||||
const { groups, note } = await fetchUsages(c);
|
||||
const { groups, note } = await fetchUsages(c, request);
|
||||
replaceUsageTab(id, { ...base, loading: false, groups, error: null, note });
|
||||
} catch (e) {
|
||||
// Cancelled here (closed tab, rerun): a newer state already replaced it.
|
||||
if (isCancelled(e) && !inflight.has(id)) return;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return;
|
||||
reportError(e);
|
||||
} finally {
|
||||
if (inflight.get(id) === request) inflight.delete(id);
|
||||
}
|
||||
};
|
||||
await run(null);
|
||||
@@ -232,11 +264,15 @@ export async function showUsagesPopup() {
|
||||
hint("Open a file to show usages");
|
||||
return;
|
||||
}
|
||||
if (popupRequest !== null) void commands.cancelRequest(popupRequest).catch(() => {});
|
||||
const request = nextRequestId();
|
||||
popupRequest = request;
|
||||
try {
|
||||
const { groups, note } = await fetchUsages(c);
|
||||
const { groups, note } = await fetchUsages(c, request);
|
||||
const total = usageCount(groups);
|
||||
if (!total) {
|
||||
hint(c.word ? `No usages of '${c.word}' found` : "No usages found");
|
||||
const none = c.word ? `No usages of '${c.word}' found` : "No usages found";
|
||||
hint(note ? `${none}. ${note}` : none);
|
||||
return;
|
||||
}
|
||||
if (total === 1) {
|
||||
@@ -249,5 +285,7 @@ export async function showUsagesPopup() {
|
||||
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
|
||||
} catch (e) {
|
||||
reportError(e);
|
||||
} finally {
|
||||
if (popupRequest === request) popupRequest = null;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line:
|
||||
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: async () => [loc],
|
||||
lspRequest: async () => ({ locations: [loc], incomplete: null }),
|
||||
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}`),
|
||||
|
||||
Reference in new issue
Заблокувати користувача