From 394e9919c7a4cb46ba38fe7b58d314fec40fe0a1 Mon Sep 17 00:00:00 2001 From: LU XIANGRUI <1078666705@qq.com> Date: Tue, 10 Nov 2026 00:41:30 +0900 Subject: [PATCH] feat(project-view): reveal library classes opened from jars in External Libraries (#2378) - Map jdt://, jar: and decompiled class URIs to !/ paths inside the library archive - revealPath handles them in one place, so Select Opened File and autoscroll both work Co-authored-by: LU XIANGRUI <26499420+wildcatDownstairs@users.noreply.github.com> --- src/features/project-tree/extraRoots.test.ts | 47 ++++++++++++++++ src/features/project-tree/store.ts | 11 +++- src/platform/virtualDocs.test.ts | 56 ++++++++++++++++++- src/platform/virtualDocs.ts | 58 ++++++++++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/features/project-tree/extraRoots.test.ts b/src/features/project-tree/extraRoots.test.ts index 67e3bc6de..9cde906a7 100644 --- a/src/features/project-tree/extraRoots.test.ts +++ b/src/features/project-tree/extraRoots.test.ts @@ -102,3 +102,50 @@ describe("project view with a scratches root", () => { expect(scratchLabels()).toEqual(["scratch.py"]); }); }); + +describe("project view with External Libraries", () => { + const JAR = "/m2/spring-boot-4.1.1.jar"; + const CLASS = `${JAR}!/org/springframework/boot/SpringApplication.class`; + let unregister = () => {}; + beforeEach(() => { + localStorage.clear(); + setProjectFsForTests(new MemoryFs({ "/p/pom.xml": "x", [CLASS]: "x" }, "/p"), "/p"); + initProjectTree("/p", "proj"); + unregister = registerProjectViewRoot({ + id: "externalLibraries", + label: "External Libraries", + icon: "library", + readOnly: true, + children: [ + { + label: "< Maven: spring-boot >", + icon: "maven-project", + children: [{ label: "spring-boot-4.1.1.jar", icon: "file-archive", path: `${JAR}!` }], + }, + ], + }); + }); + afterEach(() => { + unregister(); + setProjectFsForTests(null); + initProjectTree(null); + }); + + it.each([ + ["the archive entry", CLASS], + [ + "a jdtls class", + "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=demo/%5C/m2%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class", + ], + ["a Kotlin LSP class", `jar:///m2/spring-boot-4.1.1.jar!/org/springframework/boot/SpringApplication.class`], + ])("reveals %s inside its jar", (_, path) => { + expect(revealPath(path)).toBe(true); + const { expanded, revealRequest } = useProjectTree.getState(); + expect(revealRequest?.path).toBe(CLASS); + expect([...expanded].some((k) => k.endsWith(`${JAR}!/org/springframework/boot`))).toBe(true); + }); + + it("leaves classes of unlisted jars alone", () => { + expect(revealPath("jar:///elsewhere/x.jar!/p/C.class")).toBe(false); + }); +}); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 5f71a2850..d083c4aa1 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -9,6 +9,7 @@ import { call } from "@/platform/ipc"; import { commands, type FileEntry, type OutlineItem } from "@/bindings"; import { withDocument } from "@/editor/lsp/files"; import { IpcError } from "@/platform/ipc"; +import { libraryEntryPaths } from "@/platform/virtualDocs"; import { DEFAULT_TREE_OPTIONS, GROUP_PREFIX, @@ -570,11 +571,15 @@ export function dropOutlines(paths: readonly string[]) { let revealSeq = 0; /** Expands the ancestors of `path` and selects it once visible. */ -export function revealPath(path: string, focus = false) { +export function revealPath(requested: string, focus = false) { const { expanded } = get(); const project = get().root; - const root = project ? (memberRootOf(path) ?? (isSameOrInside(path, project) ? project : extraRootOf(path))) : null; - if (!root) return false; + if (!project) return false; + const rootOf = (p: string) => memberRootOf(p) ?? (isSameOrInside(p, project) ? project : extraRootOf(p)); + // Library documents (`jdt://`, `jar:` URIs) are shown as entries of their archive under External Libraries. + const path = [requested, ...libraryEntryPaths(requested)].find((p) => rootOf(p) !== null); + if (!path) return false; + const root = rootOf(path)!; // Library folders sit below group nodes (External Libraries › SDK), keyed in their own namespace. const model = currentModel(); const rootKey = root === project ? root : (model.keyOfPath.get(root) ?? root); diff --git a/src/platform/virtualDocs.test.ts b/src/platform/virtualDocs.test.ts index 86293023c..09c39b7fa 100644 --- a/src/platform/virtualDocs.test.ts +++ b/src/platform/virtualDocs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isVirtualUri, virtualContainer, virtualName, virtualParent } from "./virtualDocs"; +import { isVirtualUri, libraryEntryPaths, virtualContainer, virtualName, virtualParent } from "./virtualDocs"; import { basename, dirname, relativeDir, relativePath } from "./project"; const JDT = @@ -38,3 +38,57 @@ describe("virtual document URIs", () => { expect(relativePath("/p", "/p/src/A.java")).toBe("src/A.java"); }); }); + +describe("libraryEntryPaths", () => { + const M2 = "/Users/me/.m2/repository/org/springframework/boot/spring-boot/4.1.1"; + it.each([ + // The reported case: Go to Declaration into a Maven jar through jdtls. + [ + "jdtls class in a Maven jar", + `jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=demo/${encodeURIComponent( + `${M2}/spring-boot-4.1.1.jar`.replace(/\//g, "\\/"), + )}%3Corg.springframework.boot%28SpringApplication.class`, + [`${M2}/spring-boot-4.1.1.jar!/org/springframework/boot/SpringApplication.class`], + ], + [ + "jdtls class in a Gradle jar, default package", + "jdt://contents/a.jar//C.class?=p/%5C/g%5C/files-2.1%5C/a.jar%3C%28C.class", + ["/g/files-2.1/a.jar!/C.class"], + ], + [ + "jdtls JDK class (module in lib/src.zip)", + "jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=/javadoc_location=/x=/%3Cjava.lang%28String.class", + ["/jdk/lib/src.zip!/java.base/java/lang/String.java"], + ], + [ + "jdtls JDK 8 class (rt.jar, src.zip in the home)", + "jdt://contents/rt.jar/java.lang/String.class?=p/%5C/jdk8%5C/jre%5C/lib%5C/rt.jar%3Cjava.lang%28String.class", + ["/jdk8/jre/lib/rt.jar!/java/lang/String.class", "/jdk8/src.zip!/java/lang/String.java"], + ], + [ + "jdtls class on Windows", + "jdt://contents/a.jar/p/C.class?=x/%5C/C:%5C/m2%5C/a.jar%3Cp%28C.class", + ["C:/m2/a.jar!/p/C.class"], + ], + [ + "Kotlin LSP class (jar:///)", + "jar:///home/me/.gradle/a-1.0.jar!/org/x/Y.class", + ["/home/me/.gradle/a-1.0.jar!/org/x/Y.class"], + ], + [ + "sources jar entry (jar:file:///), binary jar listed instead", + "jar:file:///m2/my%20lib/a-1.0-sources.jar!/org/x/Y.kt", + ["/m2/my lib/a-1.0-sources.jar!/org/x/Y.kt", "/m2/my lib/a-1.0.jar!/org/x/Y.class"], + ], + ["decompiled class on disk", "file:///lib/classes/p/C.class", ["/lib/classes/p/C.class"]], + // Plain paths (src.zip entries, Go modules, node_modules, crates, site-packages) are revealed as they are. + ["archive entry path", "/jdk/lib/src.zip!/java.base/java/lang/String.java", []], + ["Go module file", "/Users/me/go/pkg/mod/github.com/x/y@v1.0.0/y.go", []], + ["node_modules file", "/p/node_modules/react/index.js", []], + ["Cargo crate file", "/Users/me/.cargo/registry/src/index.crates.io-1/serde-1.0.200/src/lib.rs", []], + ["site-packages file", "/p/.venv/lib/python3.12/site-packages/requests/api.py", []], + ["other virtual document", "untitled:Untitled-1", []], + ])("%s", (_, uri, expected) => { + expect(libraryEntryPaths(uri)).toEqual(expected); + }); +}); diff --git a/src/platform/virtualDocs.ts b/src/platform/virtualDocs.ts index b464464cc..ec99f1059 100644 --- a/src/platform/virtualDocs.ts +++ b/src/platform/virtualDocs.ts @@ -79,3 +79,61 @@ export function virtualDocument(uri: string): Promise { } return p; } + +const decode = (s: string) => { + try { + return decodeURIComponent(s); + } catch { + return s; + } +}; + +/** `/C:/x` (a drive path inside a URI) → `C:/x`. */ +const uriFsPath = (p: string) => (/^\/[A-Za-z]:\//.test(p) ? p.slice(1) : p); + +const parentDir = (p: string, levels = 1) => p.split("/").slice(0, -levels).join("/"); + +/** `(jar, entry without extension)` → the class in its jar (the binary jar for a `-sources.jar`). */ +const jarCandidates = (jar: string, stem: string) => [ + `${jar.replace(/-sources\.jar$/, ".jar")}!/${stem}.class`, + // JDK 8: `/jre/lib/rt.jar` with its sources in `/src.zip`. + ...(jar.endsWith("/rt.jar") ? [`${parentDir(jar, 3)}/src.zip!/${stem}.java`] : []), +]; + +/** + * Where a library document URI lies in the Project view's External Libraries, + * as `!/` paths, most specific first: jdtls' `jdt://contents/…` + * classes (dependency jars, the JDK's modules in `lib/src.zip`), `jar:` entries + * (classes and sources jars) and decompiled `file:` classes. Empty for anything + * else; a missing entry still leads to its package folder. + */ +export function libraryEntryPaths(uri: string): string[] { + const base = uri.split(/[?#]/)[0]!; + const scheme = base.slice(0, Math.max(base.indexOf(":"), 0)).toLowerCase(); + if (scheme === "file") return isClassFile(base) ? [uriFsPath(decode(base.replace(/^file:(\/\/)?/, "")))] : []; + if (scheme === "jar") { + const m = /^jar:(?:file:)?(?:\/\/)?(.*?)!\/(.*)$/.exec(base); + if (!m) return []; + const jar = uriFsPath(decode(m[1]!)); + const entry = decode(m[2]!); + const exact = `${jar}!/${entry}`; + const stem = entry.replace(/\.[^./]*$/, ""); + return [exact, ...jarCandidates(jar, stem).filter((p) => p !== exact)]; + } + if (scheme !== "jdt") return []; + // jdt://contents///?=//lib/jrt-fs.jar`, sources per module in `/lib/src.zip`. + if (jar.endsWith("/jrt-fs.jar")) return [`${parentDir(jar)}/src.zip!/${module}/${stem}.java`]; + return jarCandidates(jar, stem); +}