дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
17 files changed
+587
-6
No files matched your search
@@ -28,6 +28,7 @@ import {
|
||||
menuGroupItems,
|
||||
springEditorExtension,
|
||||
} from "./spring/editor";
|
||||
import { registerSpringEndpointProvider } from "./spring/routeIndex";
|
||||
|
||||
const SPRING_WHEN = "editorLangId == java || editorLangId == kotlin";
|
||||
|
||||
@@ -112,6 +113,7 @@ async function newRequest(directory?: string) {
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerSpringEndpointProvider(),
|
||||
useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) resetHttpClient();
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { commands, emitLocalIdeEvent } from "@/platform/ipc";
|
||||
import { ipcPath } from "@/platform/paths";
|
||||
import { searchEndpoints } from "@/workbench/contributions";
|
||||
import { clearRouteIndex, isRouteSource, registerSpringEndpointProvider, routeIndex } from "./routeIndex";
|
||||
|
||||
const scanSources = vi.spyOn(commands, "springRouteSources");
|
||||
|
||||
const source = (method = "searchMenus", url = "/menu/search") => ({
|
||||
path: "/p/DeepLinkController.java",
|
||||
text: `@RestController
|
||||
@RequestMapping("/deeplink")
|
||||
class DeepLinkController {
|
||||
@PostMapping("${url}")
|
||||
String ${method}() { return "ok"; }
|
||||
}`,
|
||||
});
|
||||
|
||||
let dispose: (() => void) | undefined;
|
||||
afterEach(() => {
|
||||
dispose?.();
|
||||
dispose = undefined;
|
||||
clearRouteIndex();
|
||||
scanSources.mockReset();
|
||||
});
|
||||
|
||||
describe("the request-path index follows the workspace", () => {
|
||||
it("is invalidated by a change to a mapping source", () => {
|
||||
for (const [path, expected] of [
|
||||
["/p/A.java", true],
|
||||
["/p/B.kt", true],
|
||||
["/p/C.kts", true],
|
||||
["/p/pom.xml", false],
|
||||
["/p/notes.md", false],
|
||||
] as const) {
|
||||
expect(isRouteSource(path), path).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Spring endpoint provider", () => {
|
||||
it("scans once per root with method coordinates and rescans for another root", async () => {
|
||||
scanSources.mockResolvedValue({ status: "ok", data: [source()] });
|
||||
expect(await routeIndex(null)).toEqual([]);
|
||||
const first = await routeIndex("/p");
|
||||
expect(first).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
url: "/deeplink/menu/search",
|
||||
controller: "DeepLinkController",
|
||||
methodName: "searchMenus",
|
||||
path: "/p/DeepLinkController.java",
|
||||
rel: "DeepLinkController.java",
|
||||
line: 4,
|
||||
col: 11,
|
||||
},
|
||||
]);
|
||||
expect(await routeIndex("/p")).toBe(first);
|
||||
expect(scanSources).toHaveBeenCalledTimes(1);
|
||||
expect(await routeIndex("/other")).toEqual([]);
|
||||
expect(scanSources).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["created", "modified", "removed"] as const)("invalidates on %s mapping sources", async (kind) => {
|
||||
scanSources.mockResolvedValue({ status: "ok", data: [source()] });
|
||||
dispose = registerSpringEndpointProvider();
|
||||
expect((await searchEndpoints("/p"))[0]?.methodName).toBe("searchMenus");
|
||||
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [ipcPath("/p/pom.xml")], removed: [] });
|
||||
await searchEndpoints("/p");
|
||||
expect(scanSources).toHaveBeenCalledTimes(1);
|
||||
scanSources.mockResolvedValue({ status: "ok", data: [source("listMenus", "/menu/list")] });
|
||||
emitLocalIdeEvent({ type: "fsChanged", created: [], modified: [], removed: [], [kind]: [ipcPath("/p/A.java")] });
|
||||
expect((await searchEndpoints("/p"))[0]?.url).toBe("/deeplink/menu/list");
|
||||
expect(scanSources).toHaveBeenCalledTimes(2);
|
||||
dispose();
|
||||
expect(await searchEndpoints("/p")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Spring request-path index, cached per project until a mapping source changes. */
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions";
|
||||
import { springControllers } from "./endpoints";
|
||||
|
||||
export function registerSpringEndpointProvider(): () => void {
|
||||
clearRouteIndex();
|
||||
const unregister = registerEndpointProvider({ id: "http.spring", endpoints: routeIndex });
|
||||
const unsubscribe = onIdeEvent("fsChanged", (e) => {
|
||||
if ([...e.created, ...e.modified, ...e.removed].some((p) => isRouteSource(String(p)))) clearRouteIndex();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unregister();
|
||||
clearRouteIndex();
|
||||
};
|
||||
}
|
||||
|
||||
let cached: { root: string; promise: Promise<EndpointEntry[]> } | null = null;
|
||||
|
||||
/** Whether `path` is a source a mapping can live in (a Java or Kotlin file). */
|
||||
export function isRouteSource(path: string): boolean {
|
||||
return /\.(java|kt|kts)$/i.test(path);
|
||||
}
|
||||
|
||||
/** Forgets the index, so the next search rescans the project. */
|
||||
export function clearRouteIndex() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** The project's routes, scanned once per project and kept in memory. */
|
||||
export function routeIndex(root: string | null): Promise<EndpointEntry[]> {
|
||||
if (!root) return Promise.resolve([]);
|
||||
if (cached?.root === root) return cached.promise;
|
||||
const promise = scan(root);
|
||||
cached = { root, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function scan(root: string): Promise<EndpointEntry[]> {
|
||||
const sources = await call(commands.springRouteSources());
|
||||
const routes: EndpointEntry[] = [];
|
||||
for (const source of sources) {
|
||||
if (!source.path.startsWith(root)) continue;
|
||||
for (const controller of springControllers(source.text)) {
|
||||
for (const endpoint of controller.endpoints) {
|
||||
const at = lineCol(source.text, endpoint.offset);
|
||||
// A mapping without a path answers nothing to look up by URL; the method
|
||||
// itself is still reachable through the symbol index.
|
||||
for (const url of endpoint.paths) {
|
||||
routes.push({
|
||||
method: endpoint.httpMethod,
|
||||
url,
|
||||
controller: controller.name,
|
||||
methodName: endpoint.methodName,
|
||||
path: source.path,
|
||||
rel: source.path.slice(root.length + 1),
|
||||
line: at.line,
|
||||
col: at.col,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
function lineCol(text: string, offset: number): { line: number; col: number } {
|
||||
let line = 0;
|
||||
let col = 0;
|
||||
for (let i = 0; i < offset && i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10 /* \n */) {
|
||||
line++;
|
||||
col = 0;
|
||||
} else {
|
||||
col++;
|
||||
}
|
||||
}
|
||||
return { line, col };
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача