diff --git a/crates/ide-host/src/ops/nav.rs b/crates/ide-host/src/ops/nav.rs index 646e3ab87..2621446fd 100644 --- a/crates/ide-host/src/ops/nav.rs +++ b/crates/ide-host/src/ops/nav.rs @@ -41,6 +41,11 @@ pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeRe Ok(host.project(window)?.index.find_files(&query, limit as usize)) } +pub async fn find_files_named(host: &Host, window: &str, name: String) -> IdeResult> { + let project = host.project(window)?; + blocking(move || Ok(project.index.files_named(&name).into_iter().map(Into::into).collect())).await +} + pub fn find_dirs(host: &Host, window: &str, query: String, limit: u32) -> IdeResult> { Ok(host.project(window)?.index.find_dirs(&query, limit as usize)) } @@ -167,6 +172,7 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("index_status", |c| async move { index_status(&c.host, &c.window) }); d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) }); d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) }); + d.add("find_files_named", |c| async move { find_files_named(&c.host, &c.window, c.arg("name")?).await }); d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) }); d.add("find_symbols", |c| async move { find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 4989a4f90..22e41f133 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -290,6 +290,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "deploy_coverage" | "deploy_test_connection" | "find_files" + | "find_files_named" | "find_dirs" | "find_symbols" | "mark_recent" diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index 58562c760..48ef77dcb 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -234,6 +234,15 @@ impl ProjectIndex { matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit) } + /// All indexed files with this exact basename, for resolving printed source locations. + /// Unlike interactive fuzzy search, this must not truncate candidates before package matching. + pub fn files_named(&self, name: &str) -> Vec { + let data = self.shared.data.read(); + let mut files: Vec<_> = data.files.iter().flatten().filter(|file| file.name() == name).collect(); + files.sort_by(|a, b| a.rel.len().cmp(&b.rel.len()).then_with(|| a.rel.cmp(&b.rel))); + files.into_iter().map(|file| file.path.clone()).collect() + } + /// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to /// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is /// indexed yet (first scan without a cache). diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index cd89e2bc5..75ddbf33b 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -57,6 +57,27 @@ fn files(idx: &ProjectIndex, q: &str) -> Vec { idx.find_files(q, 20).into_iter().map(|m| m.rel_path).collect() } +#[test] +fn exact_file_names_keep_all_source_candidates() { + let f = Fixture::new(&[ + ("src/OtherOrders.kt", "package other"), + ("src/Orders.kt.bak", "backup"), + ("target/Orders.kt", "excluded"), + ("src/orders.kt", "different case"), + ]); + for i in 0..65 { + f.write(&format!("module{i}/Orders.kt"), &format!("package module{i}")); + } + let source = f.write("service/src/Orders.kt", "package com.example.orders"); + let idx = f.open(); + let candidates = idx.files_named("Orders.kt"); + assert_eq!(candidates.len(), 66); + assert!(candidates.contains(&source)); + assert!(candidates.iter().all(|path| path.file_name().unwrap() == "Orders.kt")); + assert_eq!(candidates, idx.files_named("Orders.kt")); + assert!(idx.files_named("Missing.kt").is_empty()); +} + fn symbols(idx: &ProjectIndex, q: &str, filter: SymbolFilter) -> Vec { idx.find_symbols(q, filter, 20).into_iter().map(|m| m.name).collect() } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index edd9d994e..ab3358471 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -209,6 +209,7 @@ pub fn builder() -> tauri_specta::Builder { nav::index_file_extensions, nav::spring_route_sources, nav::find_files, + nav::find_files_named, nav::find_dirs, nav::find_symbols, nav::mark_recent, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index d5cd946d9..910d602d2 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -28,6 +28,12 @@ pub fn find_files(window: Window, state: State<'_, AppState>, query: String, lim ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } +#[tauri::command] +#[specta::specta] +pub async fn find_files_named(window: Window, state: State<'_, AppState>, name: String) -> IdeResult> { + ide_host::ops::nav::find_files_named(&state.host, window.label(), name).await +} + /// Folders holding indexed files, for `@` references. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index e6e6cd16a..21785d2a3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -368,6 +368,7 @@ export const commands = { /** Spring Web mapping sources for the request-path index of Search Everywhere. */ springRouteSources: () => typedError(__TAURI_INVOKE("spring_route_sources")), findFiles: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_files", { query, limit })), + findFilesNamed: (name: string) => typedError(__TAURI_INVOKE("find_files_named", { name })), /** Folders holding indexed files, for `@` references. */ findDirs: (query: string, limit: number) => typedError(__TAURI_INVOKE("find_dirs", { query, limit })), findSymbols: (query: string, filter: SymbolFilter, limit: number) => typedError(__TAURI_INVOKE("find_symbols", { query, filter, limit })), diff --git a/src/features/run/navigate.test.ts b/src/features/run/navigate.test.ts index bd3c041e3..32bc39dac 100644 --- a/src/features/run/navigate.test.ts +++ b/src/features/run/navigate.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { jvmFrameTarget } from "./links"; +import { useRemote } from "@/platform/remote"; const files: Record = { "/p/src/main/java/com/acme/Util.java": "package com.acme;\nclass Util {}", @@ -15,9 +16,12 @@ vi.mock("@/platform/ipc", () => ({ commands: { fsReadDir: async () => [], fsReadText: async (path: string) => files[path] ?? "", - findFiles: async (name: string) => + findFilesNamed: async (name: string) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)), + indexStatus: async () => ({ files: Object.keys(files).length }), + findFiles: async (name: string, limit: number) => Object.keys(files) .filter((p) => p.endsWith(`/${name}`)) + .slice(0, limit) .map((path) => ({ path, relPath: path.slice(3) })), lspWorkspaceSymbols: async () => symbols.list, }, @@ -28,6 +32,7 @@ const { resolveTarget } = await import("./navigate"); describe("JVM stack frame resolution", () => { afterEach(() => { symbols.list = []; + useRemote.setState({ session: null }); }); it("picks the file of the frame's package, not any file of that name", async () => { @@ -43,6 +48,51 @@ describe("JVM stack frame resolution", () => { ); }); + it.each(["java", "kt", "scala", "groovy"])("keeps the package in a crowded %s source lookup", async (ext) => { + const otherPaths = Array.from({ length: 60 }, (_, i) => `/p/module${i}/src/other${i}/AService.${ext}`); + const source = `/p/service/src/main/${ext}/com/example/AService.${ext}`; + for (const [i, path] of otherPaths.entries()) files[path] = `package other${i};`; + files[source] = "package com.example;"; + symbols.list = [ + { + name: "AService", + kind: "class", + container: "com.example", + location: { path: "jdt://contents/AService.class", uri: "jdt://contents/AService.class" }, + }, + ]; + try { + expect(await resolveTarget(jvmFrameTarget("com.example.AService", `AService.${ext}`, 52), "/p")).toBe(source); + } finally { + for (const path of [...otherPaths, source]) delete files[path]; + } + }); + + it.each([false, true])( + "finds nonstandard package directories beyond the fuzzy limit (old remote: %s)", + async (legacy) => { + if (legacy) + useRemote.setState({ + session: { + target: { kind: "wsl", distro: "Ubuntu" }, + root: "/p", + host: "Ubuntu", + os: "linux", + commands: ["find_files", "index_status"], + }, + }); + const others = Array.from({ length: 65 }, (_, i) => `/p/other${i}/Orders.kt`); + const source = "/p/special/src/Orders.kt"; + for (const [i, path] of others.entries()) files[path] = `package other${i}`; + files[source] = "package com.example.orders\nfun main() {}"; + try { + expect(await resolveTarget(jvmFrameTarget("com.example.orders.OrdersKt", "Orders.kt", 1), "/p")).toBe(source); + } finally { + for (const path of [...others, source]) delete files[path]; + } + }, + ); + it("library classes open the language server's source, else nothing", async () => { expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBeNull(); symbols.list = [ diff --git a/src/features/run/navigate.ts b/src/features/run/navigate.ts index 3d4eee6ac..feadf2fde 100644 --- a/src/features/run/navigate.ts +++ b/src/features/run/navigate.ts @@ -1,6 +1,7 @@ /** Opens file locations printed in run output (resolving relative and bare names). */ import { call, commands, isTauri, nextRequestId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; +import { useRemote } from "@/platform/remote"; import { editorService, notifications } from "@/workbench/contributions"; import { streamSearch } from "@/platform/textSearch"; import { joinPath, type FileTarget } from "./links"; @@ -36,18 +37,24 @@ async function declaresPackage(path: string, pkg: string): Promise { async function findByName(target: FileTarget): Promise { const name = target.path.split("/").pop() ?? target.path; try { - const hits = await call(commands.findFiles(name, 50)); + const remote = useRemote.getState().session; + // Older remote servers only have fuzzy search: request the whole inventory before filtering. + const hits = + remote && !remote.commands.includes("find_files_named") + ? (await call(commands.findFiles(name, (await call(commands.indexStatus())).files))) + .filter((h) => h.relPath.split("/").pop() === name) + .map((h) => h.path) + : await call(commands.findFilesNamed(name)); const tail = target.path.replace(/^\.?\//, ""); - const exact = hits.find((h) => h.relPath === tail || h.relPath.endsWith(`/${tail}`)); - if (exact) return exact.path; - const named = hits.filter((h) => h.relPath.split("/").pop() === name); + const exact = hits.find((path) => path === tail || path.endsWith(`/${tail}`)); + if (exact) return exact; // A stack frame names its class: a same-named file of another package is not it. if (target.className) { const pkg = packageOf(target.className); - for (const h of named) if (await declaresPackage(h.path, pkg)) return h.path; + for (const path of hits) if (await declaresPackage(path, pkg)) return path; return null; } - return named[0]?.path ?? null; + return hits[0] ?? null; } catch { return null; }