diff --git a/src/features/http-client/spring/routeIndex.test.ts b/src/features/http-client/spring/routeIndex.test.ts index 28521dda4..abb394bac 100644 --- a/src/features/http-client/spring/routeIndex.test.ts +++ b/src/features/http-client/spring/routeIndex.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { commands, emitLocalIdeEvent } from "@/platform/ipc"; import { ipcPath } from "@/platform/paths"; +import { useProject } from "@/platform/project"; import { searchEndpoints } from "@/workbench/contributions"; import { clearRouteIndex, isRouteSource, registerSpringEndpointProvider, routeIndex } from "./routeIndex"; @@ -22,8 +23,22 @@ afterEach(() => { dispose = undefined; clearRouteIndex(); scanSources.mockReset(); + useProject.setState({ project: null }); }); +function workspace(projects: string[]) { + useProject.setState({ + project: { + root: "/workspace", + name: "Workspace", + settings: {}, + trusted: true, + isGit: false, + workspace: { version: 1, name: "Workspace", projects }, + }, + }); +} + describe("the request-path index follows the workspace", () => { it("is invalidated by a change to a mapping source", () => { for (const [path, expected] of [ @@ -39,6 +54,43 @@ describe("the request-path index follows the workspace", () => { }); describe("the Spring endpoint provider", () => { + it("includes linked members with member-relative paths and excludes files outside every member", async () => { + workspace(["/p", "/linked", "/linked/nested"]); + scanSources.mockResolvedValue({ + status: "ok", + data: [ + source(), + { ...source(), path: "/linked/src/Controller.kt" }, + { ...source(), path: "/linked/nested/src/NestedController.java" }, + { ...source(), path: "/linked-other/Outside.java" }, + { ...source(), path: "/workspace/State.java" }, + ], + }); + expect(await routeIndex("/workspace")).toMatchObject([ + { path: "/p/DeepLinkController.java", rel: "DeepLinkController.java", line: 4, col: 11 }, + { path: "/linked/src/Controller.kt", rel: "src/Controller.kt", line: 4, col: 11 }, + { path: "/linked/nested/src/NestedController.java", rel: "src/NestedController.java" }, + ]); + }); + + it("rescans when members are added or removed without changing the workspace root", async () => { + scanSources.mockResolvedValue({ + status: "ok", + data: [source(), { ...source(), path: "/linked/Controller.java" }], + }); + workspace(["/p"]); + expect(await routeIndex("/workspace")).toHaveLength(1); + workspace(["/p", "/linked"]); + const both = await routeIndex("/workspace"); + expect(both).toHaveLength(2); + expect(await routeIndex("/workspace")).toBe(both); + workspace(["/linked"]); + expect(await routeIndex("/workspace")).toMatchObject([{ path: "/linked/Controller.java" }]); + workspace([]); + expect(await routeIndex("/workspace")).toEqual([]); + expect(scanSources).toHaveBeenCalledTimes(4); + }); + 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([]); diff --git a/src/features/http-client/spring/routeIndex.ts b/src/features/http-client/spring/routeIndex.ts index 2388a828c..4da14a5b1 100644 --- a/src/features/http-client/spring/routeIndex.ts +++ b/src/features/http-client/spring/routeIndex.ts @@ -1,6 +1,7 @@ /** Spring request-path index, cached per project until a mapping source changes. */ -import { call, commands, onIdeEvent } from "@/platform/ipc"; +import { call, commands, onIdeEvent, type ProjectInfo } from "@/platform/ipc"; import { ipcPath, isSameOrInside } from "@/platform/paths"; +import { projectFolder, relativePath, useProject } from "@/platform/project"; import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions"; import { springControllers } from "./endpoints"; @@ -17,7 +18,7 @@ export function registerSpringEndpointProvider(): () => void { }; } -let cached: { root: string; promise: Promise } | null = null; +let cached: { key: string; promise: Promise } | null = null; /** Whether `path` is a source a mapping can live in (a Java or Kotlin file). */ export function isRouteSource(path: string): boolean { @@ -32,18 +33,23 @@ export function clearRouteIndex() { /** The project's routes, scanned once per project and kept in memory. */ export function routeIndex(root: string | null): Promise { if (!root) return Promise.resolve([]); - if (cached?.root === root) return cached.promise; - const promise = scan(root); - cached = { root, promise }; + const current = useProject.getState().project; + const project = current?.root === root ? current : null; + const key = JSON.stringify([root, project?.workspace?.projects ?? null]); + if (cached?.key === key) return cached.promise; + const promise = scan(root, project); + cached = { key, promise }; return promise; } -async function scan(root: string): Promise { +async function scan(root: string, project: ProjectInfo | null): Promise { const sources = await call(commands.springRouteSources()); const routes: EndpointEntry[] = []; - const base = ipcPath(root).replace(/\/$/, ""); for (const source of sources) { const path = ipcPath(source.path); + const folder = project ? projectFolder(project, path)?.root : root; + if (!folder) continue; + const base = ipcPath(folder).replace(/\/$/, ""); if (!isSameOrInside(path, base)) continue; for (const controller of springControllers(source.text)) { for (const endpoint of controller.endpoints) { @@ -57,7 +63,7 @@ async function scan(root: string): Promise { controller: controller.name, methodName: endpoint.methodName, path, - rel: path.slice(base.length + 1), + rel: relativePath(base, path), line: at.line, col: at.col, });