feat(search): find a Spring endpoint by its request path (#820)

Search Everywhere lists Spring Web endpoints matched by request path (context path tolerated) or handler method name, and Enter opens the handler. The host scans Java/Kotlin sources that name a mapping; http-client parses and indexes them and hands the entries to search through an endpoint contribution, so search does not import http-client. The index is invalidated when a Java/Kotlin file changes.

Route source path assertions compare by path component so they hold on Windows.

Co-authored-by: misswell <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-02 22:18:11 +08:00
1 джерело 8ea8c98a39
коміт 4e9442b499
17 files changed
+587 -6

No files matched your search

+35
Переглянути файл
@@ -1317,6 +1317,41 @@ export function runAnythingProviderList(): RunAnythingProvider[] {
);
}
// ---------------------------------------------------------------------------
// Project endpoints supplied by framework features for request-path search.
export interface EndpointEntry {
/** `GET`, `POST`, … */
method: string;
/** Class-level and method-level mapping joined; `{var}` kept. */
url: string;
controller: string;
methodName: string;
path: string;
rel: string;
line: number;
col: number;
}
export interface EndpointProvider {
id: string;
endpoints(root: string): Promise<EndpointEntry[]>;
}
const endpointProviders = new Map<string, EndpointProvider>();
export function registerEndpointProvider(p: EndpointProvider): () => void {
endpointProviders.set(p.id, p);
return () => {
if (endpointProviders.get(p.id) === p) endpointProviders.delete(p.id);
};
}
export async function searchEndpoints(root: string | null): Promise<EndpointEntry[]> {
if (!root) return [];
return (await Promise.all([...endpointProviders.values()].map((p) => p.endpoints(root)))).flat();
}
// ---------------------------------------------------------------------------
// Status bar info text (IDEA's status bar message, e.g. "3 of 12" after Next
// Occurrence). Cleared by the next message or after a while.
+46
Переглянути файл
@@ -0,0 +1,46 @@
import { expect, it, vi } from "vitest";
import { registerEndpointProvider, searchEndpoints, type EndpointEntry } from "./contributions";
const endpoint: EndpointEntry = {
method: "GET",
url: "/menu/list",
controller: "MenuController",
methodName: "listMenus",
path: "/p/MenuController.java",
rel: "MenuController.java",
line: 4,
col: 11,
};
it("collects endpoints from all providers for the requested root", async () => {
const first = vi.fn(async () => [endpoint]);
const second = vi.fn(async () => [{ ...endpoint, url: "/other" }]);
const disposers = [
registerEndpointProvider({ id: "test.first", endpoints: first }),
registerEndpointProvider({ id: "test.second", endpoints: second }),
];
try {
expect(await searchEndpoints(null)).toEqual([]);
expect(first).not.toHaveBeenCalled();
expect(second).not.toHaveBeenCalled();
expect(await searchEndpoints("/p")).toEqual([endpoint, { ...endpoint, url: "/other" }]);
expect(first).toHaveBeenCalledWith("/p");
expect(second).toHaveBeenCalledWith("/p");
} finally {
disposers.forEach((dispose) => dispose());
}
expect(await searchEndpoints("/p")).toEqual([]);
});
it("keeps a replacement provider registered when its predecessor is disposed", async () => {
const disposeOld = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [] });
const disposeNew = registerEndpointProvider({ id: "test.replaced", endpoints: async () => [endpoint] });
try {
disposeOld();
expect(await searchEndpoints("/p")).toEqual([endpoint]);
} finally {
disposeOld();
disposeNew();
}
expect(await searchEndpoints("/p")).toEqual([]);
});