feat(project-view): reveal library classes opened from jars in External Libraries (#2378)

- Map jdt://, jar: and decompiled class URIs to <archive>!/<entry> paths inside the library archive
- revealPath handles them in one place, so Select Opened File and autoscroll both work

Co-authored-by: LU XIANGRUI <[email protected]>
Цей коміт міститься в:
LU XIANGRUIтаLU XIANGRUI authored and GitHub committed 2026-11-09 23:41:30 +08:00
1 джерело e839e5dfb7
коміт 394e9919c7
4 files changed
+168 -4

No files matched your search

+47
Переглянути файл
@@ -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);
});
});
+8 -3
Переглянути файл
@@ -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);
+55 -1
Переглянути файл
@@ -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/[email protected]/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);
});
});
+58
Переглянути файл
@@ -79,3 +79,61 @@ export function virtualDocument(uri: string): Promise<VirtualDocument> {
}
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: `<home>/jre/lib/rt.jar` with its sources in `<home>/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 `<archive>!/<entry>` 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/<jar or module>/<package>/<Class.class>?=<project>/<escaped jar path><package(Class.class
const segs = base
.replace(/^jdt:\/\/contents\//, "")
.split("/")
.map(decode);
if (segs.length !== 3) return [];
const [module, pkg, file] = segs as [string, string, string];
const stem = [...(pkg ? pkg.split(".") : []), file.replace(/\.(class|java)$/, "")].join("/");
const query = decode(uri.split("?")[1] ?? "");
const rest = query.slice(query.indexOf("/") + 1);
const jar = query.includes("/") ? uriFsPath(rest.replace(/\\/g, "").split(/[<(`=]/)[0]!) : "";
if (!jar) return [];
// JDK 9+: `<home>/lib/jrt-fs.jar`, sources per module in `<home>/lib/src.zip`.
if (jar.endsWith("/jrt-fs.jar")) return [`${parentDir(jar)}/src.zip!/${module}/${stem}.java`];
return jarCandidates(jar, stem);
}