mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00: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 <28730815+misswell@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
misswell
Claude Opus 5.5
parent
8ea8c98a39
commit
4e9442b499
@@ -22,6 +22,7 @@ pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
pub mod routes;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Spring Web mappings, source side: the project's Java and Kotlin files that could
|
||||
//! declare a route, with their text. The UI owns the annotation parser and the index
|
||||
//! it feeds, so this side stays a cheap scan.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Files handed out at most, and bytes in total: a project's controllers fit well
|
||||
/// inside these, and the UI indexes them once.
|
||||
const MAX_FILES: usize = 1024;
|
||||
const MAX_BYTES: usize = 48 << 20;
|
||||
/// A single source bigger than this is not a controller.
|
||||
const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSource {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The files Spring mappings can live in: Java and Kotlin sources.
|
||||
fn declares_mappings(path: &Path) -> bool {
|
||||
matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts"))
|
||||
}
|
||||
|
||||
/// Files under `root` whose text names a mapping: `ignore` rules and `excludes`
|
||||
/// decide what counts, and the UI ranks the matches itself.
|
||||
pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
let found: Mutex<Vec<RouteSource>> = Mutex::new(Vec::new());
|
||||
let bytes = AtomicUsize::new(0);
|
||||
ide_vfs::walk_files(root, excludes, |path| {
|
||||
if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
// Only a file that names a mapping annotation is worth parsing.
|
||||
if !text.contains("Mapping") {
|
||||
return;
|
||||
}
|
||||
let Ok(mut found) = found.lock() else { return };
|
||||
if found.len() >= MAX_FILES {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_default();
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
files
|
||||
}
|
||||
|
||||
/// The folders to scan: the project folder itself first, then content roots outside it
|
||||
/// (`content_roots` lists only the ones outside, so the project folder is never implied —
|
||||
/// forgetting it scans nothing).
|
||||
fn scan_roots(root: &Path, content_roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots);
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every mapping source of the project, for the request-path index.
|
||||
pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult<Vec<RouteSource>> {
|
||||
let project = host.project(window)?;
|
||||
let excludes = project.search_excludes();
|
||||
let mut out = Vec::new();
|
||||
for root in scan_roots(&project.root, project.content_roots()) {
|
||||
out.extend(sources_in(&root, &excludes));
|
||||
}
|
||||
out.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
out.dedup_by(|a, b| a.path == b.path);
|
||||
match out.len() {
|
||||
0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()),
|
||||
n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_project_folder_is_scanned_even_without_other_content_roots() {
|
||||
let root = Path::new("/p/demo");
|
||||
assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]);
|
||||
assert_eq!(
|
||||
scan_roots(root, vec![PathBuf::from("/p/elsewhere")]),
|
||||
[PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_java_and_kotlin_sources_are_read() {
|
||||
for (name, expected) in
|
||||
[("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)]
|
||||
{
|
||||
assert_eq!(declares_mappings(Path::new(name)), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_name_no_mapping_are_left_out() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
|
||||
std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap();
|
||||
std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap();
|
||||
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &[]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
|
||||
assert!(files[0].text.contains("@GetMapping"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_folders_are_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("module/src")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("build")).unwrap();
|
||||
std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap();
|
||||
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
caches::caches_schedule_invalidation,
|
||||
caches::app_restart,
|
||||
nav::index_status,
|
||||
nav::spring_route_sources,
|
||||
nav::find_files,
|
||||
nav::find_symbols,
|
||||
nav::mark_recent,
|
||||
|
||||
@@ -22,6 +22,16 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim
|
||||
ide_host::ops::nav::find_files(&state.host, window.label(), query, limit)
|
||||
}
|
||||
|
||||
/// Spring Web mapping sources for the request-path index of Search Everywhere.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn spring_route_sources(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> IdeResult<Vec<ide_host::ops::routes::RouteSource>> {
|
||||
ide_host::ops::routes::spring_route_sources(&state.host, window.label())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn find_symbols(
|
||||
|
||||
@@ -310,6 +310,8 @@ export const commands = {
|
||||
/** Quits through every window's unsaved-changes check, then starts again. */
|
||||
appRestart: () => __TAURI_INVOKE<void>("app_restart"),
|
||||
indexStatus: () => typedError<IndexStatus, IdeError>(__TAURI_INVOKE("index_status")),
|
||||
/** Spring Web mapping sources for the request-path index of Search Everywhere. */
|
||||
springRouteSources: () => typedError<RouteSource[], IdeError>(__TAURI_INVOKE("spring_route_sources")),
|
||||
findFiles: (query: string, limit: number) => typedError<FileMatch[], IdeError>(__TAURI_INVOKE("find_files", { query, limit })),
|
||||
findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError<SymbolMatch[], IdeError>(__TAURI_INVOKE("find_symbols", { query, filter, limit })),
|
||||
markRecent: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("mark_recent", { path })),
|
||||
@@ -5312,6 +5314,11 @@ export type RevisionChange = {
|
||||
|
||||
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
|
||||
|
||||
export type RouteSource = {
|
||||
path: string,
|
||||
text: string,
|
||||
};
|
||||
|
||||
export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize;
|
||||
|
||||
export type RunConfig_Deserialize = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FileMatches, SearchQuery } from "@/platform/ipc";
|
||||
import { SeController, type SeDeps, type SeSnapshot } from "../everywhere/controller";
|
||||
import { buildRows } from "../everywhere/model";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
@@ -195,3 +196,29 @@ describe("SeController", () => {
|
||||
expect(searchText.mock.calls.at(-1)![1].scope).toEqual({ kind: "fileSet", set: "test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Endpoints in the All tab", () => {
|
||||
it("answers a request path with the controller method that serves it", async () => {
|
||||
const routes = [
|
||||
{
|
||||
method: "POST",
|
||||
url: "/deeplink/menu/search",
|
||||
controller: "DeepLinkController",
|
||||
methodName: "searchMenus",
|
||||
path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java",
|
||||
rel: "tg-tenant-server/src/main/java/DeepLinkController.java",
|
||||
line: 45,
|
||||
col: 4,
|
||||
},
|
||||
];
|
||||
const { c, last } = setup({ root: () => "/p", routes: async () => routes });
|
||||
c.run({ query: "/tg-tenant/deeplink/menu/search", tab: "all", includeNonProject: false, extra: {} });
|
||||
await flush();
|
||||
const section = last().sections.get("endpoints");
|
||||
expect(section?.items).toHaveLength(1);
|
||||
expect(section?.items[0]).toMatchObject({ kind: "symbol", container: "DeepLinkController.searchMenus", line: 45 });
|
||||
// The All tab mixes its sections into one list; an endpoint has to be in it.
|
||||
const rows = buildRows(last().sections, { headers: false, mix: "/tg-tenant/deeplink/menu/search" });
|
||||
expect(rows.map((r) => (r.kind === "symbol" ? r.container : r.kind))).toContain("DeepLinkController.searchMenus");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,7 +250,7 @@ describe("items and rows", () => {
|
||||
it("sectionsFor picks sources per tab and empty query", () => {
|
||||
expect(sectionsFor("all", true)).toEqual(["recent", "recentActions"]);
|
||||
expect(sectionsFor("classes", true)).toEqual([]);
|
||||
expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "actions", "text"]);
|
||||
expect(sectionsFor("all", false)).toEqual(["classes", "files", "symbols", "endpoints", "actions", "text"]);
|
||||
expect(sectionsFor("text", false)).toEqual(["text"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EndpointEntry } from "@/workbench/contributions";
|
||||
import { matchRoutes } from "../everywhere/routes";
|
||||
|
||||
const route = (over: Partial<EndpointEntry> = {}): EndpointEntry => ({
|
||||
method: "POST",
|
||||
url: "/deeplink/menu/search",
|
||||
controller: "DeepLinkController",
|
||||
methodName: "searchMenus",
|
||||
path: "/p/tg-tenant-server/src/main/java/DeepLinkController.java",
|
||||
rel: "tg-tenant-server/src/main/java/DeepLinkController.java",
|
||||
line: 45,
|
||||
col: 4,
|
||||
...over,
|
||||
});
|
||||
|
||||
const routes: EndpointEntry[] = [
|
||||
route(),
|
||||
route({
|
||||
method: "GET",
|
||||
url: "/deeplink/menu/{menuId}",
|
||||
methodName: "getMenuDetail",
|
||||
line: 52,
|
||||
col: 4,
|
||||
}),
|
||||
];
|
||||
|
||||
describe("endpoints in Search Everywhere", () => {
|
||||
it("finds a controller method by the request path it answers", () => {
|
||||
for (const query of [
|
||||
"/deeplink/menu/search",
|
||||
"deeplink/menu/search",
|
||||
"/tg-tenant/deeplink/menu/search",
|
||||
"POST /tg-tenant/deeplink/menu/search",
|
||||
"post /deeplink/menu/search",
|
||||
]) {
|
||||
const { items } = matchRoutes(routes, query, 5);
|
||||
expect(items[0]?.container, query).toBe("DeepLinkController.searchMenus");
|
||||
expect(items[0]?.display, query).toBe("POST /deeplink/menu/search");
|
||||
}
|
||||
});
|
||||
|
||||
it("honours an HTTP method prefix", () => {
|
||||
expect(matchRoutes(routes, "GET /deeplink/menu/x", 5).items).toEqual([]);
|
||||
expect(matchRoutes(routes, "GET /deeplink/menu", 5).items[0]?.container).toBe("DeepLinkController.getMenuDetail");
|
||||
});
|
||||
|
||||
it("finds it by controller or method name too", () => {
|
||||
expect(matchRoutes(routes, "searchMenus", 5).items[0]?.container).toBe("DeepLinkController.searchMenus");
|
||||
expect(matchRoutes(routes, "DeepLinkController.getMenuDetail", 5).items[0]?.container).toBe(
|
||||
"DeepLinkController.getMenuDetail",
|
||||
);
|
||||
expect(matchRoutes(routes, "deeplink", 5).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("says nothing for an empty or unrelated query", () => {
|
||||
expect(matchRoutes(routes, "", 5).items).toEqual([]);
|
||||
expect(matchRoutes(routes, " ", 5).items).toEqual([]);
|
||||
expect(matchRoutes(routes, "unrelated-thing", 5).items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@ import type {
|
||||
SymbolMatch,
|
||||
WorkspaceSymbol,
|
||||
} from "@/platform/ipc";
|
||||
import type { SettingsSearchEntry } from "@/workbench/contributions";
|
||||
import type { EndpointEntry, SettingsSearchEntry } from "@/workbench/contributions";
|
||||
import { parseLocationQuery } from "../common/query";
|
||||
import { matchRoutes } from "./routes";
|
||||
import { passesFilter, type ResolvedFilter } from "./filters";
|
||||
import {
|
||||
ALL_TAB_LIMIT,
|
||||
@@ -56,6 +57,8 @@ export interface SeDeps {
|
||||
now(): number;
|
||||
/** Whether `path` is in a root-based named scope ("Project Test Files" …). */
|
||||
inFileSet?(set: ProjectFileSet, path: string): boolean;
|
||||
/** Endpoints of the project, for request-path matches. */
|
||||
routes?(): Promise<EndpointEntry[]>;
|
||||
}
|
||||
|
||||
export interface SeRequest {
|
||||
@@ -257,6 +260,9 @@ export class SeController {
|
||||
case "symbols":
|
||||
await this.startSymbols(id, gen, text, limit, req);
|
||||
break;
|
||||
case "endpoints":
|
||||
await this.startEndpoints(id, gen, text, limit, req);
|
||||
break;
|
||||
case "actions": {
|
||||
const hits = matchActions(this.deps.actions(), text, {
|
||||
isEnabled: (a) => this.deps.isEnabled(a),
|
||||
@@ -284,6 +290,14 @@ export class SeController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Project endpoints by request path (`/menu/search`), ranked in memory. */
|
||||
private async startEndpoints(id: SectionId, gen: number, text: string, limit: number, req: SeRequest) {
|
||||
const routes = (await this.deps.routes?.().catch((): EndpointEntry[] => [])) ?? [];
|
||||
const { items, hasMore } = matchRoutes(routes, text, limit);
|
||||
const keep = this.scopeFilter(req);
|
||||
this.set(id, gen, keep ? items.filter((i) => keep(i.path)) : items, hasMore, false);
|
||||
}
|
||||
|
||||
private async startSymbols(id: "classes" | "symbols", gen: number, text: string, limit: number, req: SeRequest) {
|
||||
const typesOnly = id === "classes";
|
||||
const opts = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { allActions, type Action } from "@/platform/actions";
|
||||
import { evaluateWhen, type ContextKeys } from "@/platform/context";
|
||||
import { call, commands, nextRequestId, type LanguageInfo } from "@/platform/ipc";
|
||||
import { inFileSet } from "@/platform/projectRoots";
|
||||
import { searchableSettings } from "@/workbench/contributions";
|
||||
import { searchEndpoints, searchableSettings } from "@/workbench/contributions";
|
||||
import { projectRoot } from "../common/open";
|
||||
import { cancelRequest, streamSearch } from "../common/textSearch";
|
||||
import type { SeDeps } from "./controller";
|
||||
@@ -33,6 +33,7 @@ export function ipcDeps(ctx: ContextKeys): SeDeps {
|
||||
root: projectRoot,
|
||||
now: () => performance.now(),
|
||||
inFileSet: (set, path) => inFileSet(set, path),
|
||||
routes: () => searchEndpoints(projectRoot()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { basename, isTypeKind, relDir, symbolDisplayName } from "../common/open"
|
||||
import { byScore, matchText } from "../common/rank";
|
||||
|
||||
export type SeTab = "all" | "classes" | "files" | "symbols" | "actions" | "text";
|
||||
export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "actions" | "text";
|
||||
export type SectionId = "recent" | "recentActions" | "classes" | "files" | "symbols" | "endpoints" | "actions" | "text";
|
||||
|
||||
export const TABS: ReadonlyArray<{ id: SeTab; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
@@ -27,6 +27,7 @@ export const SECTION_ORDER: readonly SectionId[] = [
|
||||
"classes",
|
||||
"files",
|
||||
"symbols",
|
||||
"endpoints",
|
||||
"actions",
|
||||
"text",
|
||||
];
|
||||
@@ -37,6 +38,7 @@ export const SECTION_TITLES: Record<SectionId, string> = {
|
||||
classes: "Classes",
|
||||
files: "Files",
|
||||
symbols: "Symbols",
|
||||
endpoints: "Endpoints",
|
||||
actions: "Actions",
|
||||
text: "Text",
|
||||
};
|
||||
@@ -55,7 +57,7 @@ export function sectionsFor(tab: SeTab, emptyQuery: boolean): SectionId[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return tab === "all" ? ["classes", "files", "symbols", "actions", "text"] : [tab];
|
||||
return tab === "all" ? ["classes", "files", "symbols", "endpoints", "actions", "text"] : [tab];
|
||||
}
|
||||
|
||||
export const ALL_TAB_LIMIT = 6;
|
||||
@@ -90,6 +92,8 @@ export interface SymbolItem {
|
||||
indices: number[];
|
||||
/** From the language server rather than the index. */
|
||||
lsp?: boolean;
|
||||
/** Pre-computed relevance (endpoints: the request path matched `n` segments up). */
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface ActionItem {
|
||||
@@ -376,7 +380,7 @@ export function textItems(path: string, rel: string, matches: readonly LineMatch
|
||||
// Rows
|
||||
|
||||
/** Sections the All tab mixes into one relevance-ordered list. */
|
||||
const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "actions", "text"];
|
||||
const MIXED: readonly SectionId[] = ["classes", "files", "symbols", "endpoints", "actions", "text"];
|
||||
|
||||
/**
|
||||
* Weight of a text hit: a literal substring in a line ranks below name
|
||||
@@ -391,6 +395,7 @@ export function itemWeight(item: SeItem, query: string): number {
|
||||
case "file":
|
||||
return name(item.name) ?? (name(item.rel) ?? 0) - 200;
|
||||
case "symbol":
|
||||
if (item.score !== undefined) return item.score;
|
||||
return name(item.name) ?? (item.container ? name(`${item.container}.${item.name}`) : undefined) ?? 0;
|
||||
case "action":
|
||||
case "setting":
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** Request-path and controller-method ranking for Search Everywhere. */
|
||||
import type { EndpointEntry } from "@/workbench/contributions";
|
||||
import { matchText } from "../common/rank";
|
||||
import type { SymbolItem } from "./model";
|
||||
|
||||
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE"]);
|
||||
|
||||
const strip = (s: string) => s.replace(/^\/+|\/+$/g, "");
|
||||
|
||||
function item(r: EndpointEntry, indices: number[], score: number): SymbolItem {
|
||||
const display = `${r.method} ${r.url}`;
|
||||
return {
|
||||
kind: "symbol",
|
||||
key: `r:${r.path}:${r.line}:${r.method}:${r.url}`,
|
||||
name: display,
|
||||
display,
|
||||
symbolKind: "method",
|
||||
container: `${r.controller}.${r.methodName}`,
|
||||
path: r.path,
|
||||
rel: r.rel,
|
||||
line: r.line,
|
||||
col: r.col,
|
||||
indices,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes matching `query`: a request path (`/deeplink/menu/search`, with or without
|
||||
* the servlet context path), an optional `POST ` prefix, or the controller / method
|
||||
* name. Scores: exact path, then a path suffix (a context path in front of it), then
|
||||
* a substring, then the symbol names, then a fuzzy path hit.
|
||||
*/
|
||||
export function matchRoutes(
|
||||
routes: readonly EndpointEntry[],
|
||||
query: string,
|
||||
limit: number,
|
||||
): { items: SymbolItem[]; hasMore: boolean } {
|
||||
const text = query.trim();
|
||||
if (!text || !routes.length) return { items: [], hasMore: false };
|
||||
const words = text.split(/\s+/);
|
||||
const head = (words[0] ?? "").toUpperCase();
|
||||
const method = HTTP_METHODS.has(head) ? head : null;
|
||||
const rest = (method ? words.slice(1).join(" ") : text).trim();
|
||||
const wantPath = strip(rest).toLowerCase();
|
||||
const wantName = rest.toLowerCase();
|
||||
|
||||
const hits: { item: SymbolItem; score: number }[] = [];
|
||||
for (const r of routes) {
|
||||
if (method && r.method !== method) continue;
|
||||
const target = strip(r.url).toLowerCase();
|
||||
const qualified = `${r.controller}.${r.methodName}`.toLowerCase();
|
||||
let score = 0;
|
||||
let indices: number[] = [];
|
||||
if (wantPath) {
|
||||
if (target === wantPath) score = 1000;
|
||||
else if (wantPath.includes("/") && (target.endsWith(`/${wantPath}`) || wantPath.endsWith(`/${target}`)))
|
||||
score = 900;
|
||||
else if (wantPath.length >= 3 && target.length >= 3 && (target.includes(wantPath) || wantPath.includes(target)))
|
||||
score = 700;
|
||||
if (score) {
|
||||
const at = `${r.method} ${r.url}`.toLowerCase().lastIndexOf(wantPath);
|
||||
if (at >= 0) indices = Array.from({ length: wantPath.length }, (_, i) => at + i);
|
||||
}
|
||||
}
|
||||
if (!score && wantName) {
|
||||
if (r.methodName.toLowerCase() === wantName) score = 800;
|
||||
else if (qualified === wantName || qualified.includes(wantName)) score = 600;
|
||||
else if (wantName.length >= 3 && target.includes(wantName)) score = 500;
|
||||
else {
|
||||
const fuzzy = matchText(target, wantName);
|
||||
if (fuzzy) score = 300 + fuzzy.score;
|
||||
}
|
||||
}
|
||||
if (score) hits.push({ item: item(r, indices, score), score });
|
||||
}
|
||||
hits.sort((a, b) => b.score - a.score || a.item.rel.localeCompare(b.item.rel) || a.item.line - b.item.line);
|
||||
return { items: hits.slice(0, limit).map((h) => h.item), hasMore: hits.length > limit };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user