дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Opening a .class file showed binary content. Class files from library jars, project output directories, navigation, Go to Class and debugger frames now all go through one route to JDT class documents: - Library classes open the attached source, or the FernFlower decompilation when there is none, read-only and marked as decompiled. java-debug's decompiled-source support is enabled, so breakpoints set in decompiled code map to the original lines and hit, and stepping into such classes lands on the right line. - Output-directory classes (target/classes, build/classes) open their project source when it exists; otherwise the decompiled text is shown read-only with breakpoints disabled, because jdt.ls returns no line mapping for classes outside the Java model. - The preferred content provider id is corrected to fernflowerContentProvider (attached sources still win), and the decompiled marker recognises the current FernFlower header. Verified on macOS with a Maven project: library class from Libraries and Go to Class, breakpoint hit and step into decompiled code, output class with and without source. Not run on Windows, Linux or Gradle projects. Fixes #394
138 рядки
5.8 KiB
TypeScript
138 рядки
5.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { VirtualDocument } from "@/platform/ipc";
|
|
|
|
const JDT = "jdt://contents/lib-1.0.jar/org.example/Lib.class?=demo/%5C/m2%5C/lib-1.0.jar%3Corg.example%28Lib.class";
|
|
const TEXT =
|
|
"// Source code is unavailable, and was generated by the Fernflower decompiler.\npackage org.example;\n\npublic class Lib {\n void run() {}\n}\n";
|
|
|
|
vi.mock("@/platform/virtualDocs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/platform/virtualDocs")>();
|
|
return {
|
|
...actual,
|
|
virtualDocument: vi.fn(async (uri: string): Promise<VirtualDocument> => {
|
|
if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`);
|
|
return {
|
|
uri,
|
|
text: TEXT,
|
|
languageId: "java",
|
|
title: "Lib.class",
|
|
description: "/m2/lib-1.0.jar",
|
|
decompiled: true,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
const { EditorController, isTextInput, useTabs, virtualInput } = await import("./controller");
|
|
const { createMemoryBackend } = await import("./backend");
|
|
const { initialTabsState } = await import("./model/tabs");
|
|
const { serializeSession } = await import("./model/session");
|
|
const { useDocMeta } = await import("./model/document");
|
|
const { registerMenuGroup } = await import("@/workbench/contributions");
|
|
const { registerActions } = await import("@/platform/actions");
|
|
const { commands } = await import("@/platform/ipc");
|
|
const { resolveMenuItems } = await import("@/ui");
|
|
|
|
let ctrl: InstanceType<typeof EditorController> | null = null;
|
|
|
|
afterEach(() => {
|
|
ctrl?.stop();
|
|
ctrl = null;
|
|
useTabs.setState(initialTabsState(), true);
|
|
});
|
|
|
|
const activeTab = () => {
|
|
const s = useTabs.getState();
|
|
const pane = s.panes[s.activePane]!;
|
|
return pane.tabs.find((t) => t.id === pane.active)!;
|
|
};
|
|
|
|
describe("virtual editor input", () => {
|
|
it("describes a library class as a text input keyed by its URI", () => {
|
|
const input = virtualInput({ uri: JDT, title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true });
|
|
expect(input).toMatchObject({ kind: "virtual", key: `virtual:${JDT}`, path: JDT });
|
|
expect(input.data).toEqual({ title: "Lib.class", description: "/m2/lib-1.0.jar", decompiled: true });
|
|
expect(isTextInput(input)).toBe(true);
|
|
expect(isTextInput({ kind: "diff", key: "d" })).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["/m2/lib.jar!/org/example/Lib.class", JDT, "virtual"],
|
|
["/p/target/classes/Lib.class", "/p/Lib.java", "file"],
|
|
["/p/build/classes/Lib.class", "file:///p/target/classes/Lib.class", "virtual"],
|
|
])("routes %s before choosing a binary viewer", async (path, target, kind) => {
|
|
const resolve = vi.spyOn(commands, "lspResolveClassFile").mockResolvedValue({ status: "ok", data: target });
|
|
ctrl = new EditorController(createMemoryBackend({ [target]: TEXT }));
|
|
ctrl.start();
|
|
try {
|
|
await ctrl.openFile(path!, { focus: false });
|
|
expect(resolve).toHaveBeenCalledWith(path);
|
|
expect(activeTab().input).toMatchObject({ path: target, kind });
|
|
expect(ctrl.slotFor(activeTab().id)!.model.ctx().readOnly).toBe(kind === "virtual");
|
|
} finally {
|
|
resolve.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("opens a URI read-only at the target and keeps it out of the saved session", async () => {
|
|
ctrl = new EditorController(createMemoryBackend({ "/p/App.java": "class App {}\n", [JDT]: TEXT }));
|
|
ctrl.start();
|
|
await ctrl.openFile("/p/App.java", { focus: false });
|
|
await ctrl.openFile(JDT, { selection: { line: 3, col: 13, endLine: 3, endCol: 16 }, focus: false });
|
|
|
|
const tab = activeTab();
|
|
expect(tab.input.kind).toBe("virtual");
|
|
expect(tab.input.key).toBe(`virtual:${JDT}`);
|
|
const slot = ctrl.slotFor(tab.id)!;
|
|
expect(slot.model.virtual).toBe(true);
|
|
expect(slot.model.ctx()).toMatchObject({ path: JDT, language: "java", readOnly: true, virtual: true });
|
|
expect(useDocMeta.getState()[JDT]).toMatchObject({ readOnly: true, banner: null });
|
|
const sel = slot.currentState().selection.main;
|
|
expect(slot.currentState().sliceDoc(sel.from, sel.to)).toBe("Lib");
|
|
|
|
// The same URI reuses its tab.
|
|
await ctrl.openFile(JDT, { focus: false });
|
|
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(2);
|
|
|
|
// Navigation history goes back to the file and forward into the library class.
|
|
await ctrl.back();
|
|
expect(activeTab().input.path).toBe("/p/App.java");
|
|
await ctrl.forward();
|
|
expect(activeTab().input.path).toBe(JDT);
|
|
|
|
const session = serializeSession(useTabs.getState(), {});
|
|
expect(JSON.stringify(session)).not.toContain("jdt:");
|
|
expect(
|
|
ctrl
|
|
.tabMenu(useTabs.getState().activePane, activeTab())
|
|
.some((i) => "label" in i && i.label === "Reveal in Project"),
|
|
).toBe(false);
|
|
|
|
// Pathless tabs still get contributed groups, with the tab's input in the context.
|
|
const disposeAction = registerActions([{ id: "test.tab", title: "Test Item", run: () => undefined }]);
|
|
const disposeGroup = registerMenuGroup({
|
|
id: "test.tab",
|
|
menus: ["editorTab"],
|
|
order: 50,
|
|
items: (_menu, { paths, input }) =>
|
|
paths.length || input?.kind !== "virtual"
|
|
? []
|
|
: [{ kind: "action", action: "test.tab", label: "Test Item", arg: input.key }],
|
|
});
|
|
try {
|
|
const items = resolveMenuItems(ctrl.tabMenu(useTabs.getState().activePane, activeTab()));
|
|
expect(items.some((i) => "label" in i && i.label === "Test Item")).toBe(true);
|
|
} finally {
|
|
disposeGroup();
|
|
disposeAction();
|
|
}
|
|
});
|
|
|
|
it("does not open a tab when the server has no contents", async () => {
|
|
ctrl = new EditorController(createMemoryBackend());
|
|
ctrl.start();
|
|
await ctrl.openFile("jdt://contents/missing.jar/p/X.class", { focus: false });
|
|
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
|
|
});
|
|
});
|