feat(csharp): navigate to decompiled assembly definitions (#1689)

Цей коміт міститься в:
Hacbit authored and GitHub committed 2026-11-07 03:19:32 +08:00
1 джерело 0f0458b966
коміт c3e8f20f53
10 files changed
+333 -13

No files matched your search

+1
Переглянути файл
@@ -126,6 +126,7 @@ const EXT_LANG: Record<string, string> = {
kt: "kotlin",
kts: "kotlin",
java: "java",
cs: "csharp",
py: "python",
gd: "gdscript",
go: "go",
+5 -1
Переглянути файл
@@ -43,7 +43,11 @@ export function VirtualBanner({
<div className={`${styles.banner} ${styles.libraryBanner}`} role="status">
<Icon name={decompiled ? "info" : "lock"} size={14} className={styles.bannerIcon} />
<span className={styles.virtualBannerText}>
{decompiled ? tx("Decompiled .class file: sources are not available.") : tx("Library source (read-only).")}
{decompiled
? path.startsWith("csharp:")
? tx("Decompiled assembly (read-only).")
: tx("Decompiled .class file: sources are not available.")
: tx("Library source (read-only).")}
{reason && ` ${tx(reason)}`}
</span>
{description && (
+11 -1
Переглянути файл
@@ -2,9 +2,19 @@ import { describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { registerEditorBanner } from "@/workbench/contributions";
import { useDocMeta, type DocMeta } from "../model/document";
import { documentBanner, EditorBanners } from "./Banner";
import { documentBanner, EditorBanners, VirtualBanner } from "./Banner";
describe("editor banners", () => {
it.each([
["jdt://contents/lib.jar/p/Lib.class", true, "Decompiled .class file: sources are not available."],
["csharp:/p/App.csproj/decompiled/Lib.cs", true, "Decompiled assembly (read-only)."],
["csharp:/p/App.csproj/generated/Lib.g.cs", false, "Library source (read-only)."],
])("describes %s", (path, decompiled, text) => {
render(<VirtualBanner path={path} decompiled={decompiled} description="Example.Library" />);
expect(screen.getByRole("status").textContent).toContain(text);
expect(screen.getByTitle("Example.Library")).toBeTruthy();
});
it("shows the document notice through the banner extension point, before contributed ones", () => {
const off = [
registerEditorBanner(documentBanner),
+61 -4
Переглянути файл
@@ -4,12 +4,23 @@ 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";
const CSHARP = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs";
const CSHARP_TEXT = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\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 === CSHARP)
return {
uri,
text: CSHARP_TEXT,
languageId: "csharp",
title: "UnityEngine.MonoBehaviour.cs",
description: "UnityEngine.CoreModule",
decompiled: true,
};
if (uri !== JDT && uri !== "file:///p/target/classes/Lib.class") throw new Error(`no contents for ${uri}`);
return {
uri,
@@ -128,10 +139,56 @@ describe("virtual editor input", () => {
}
});
it("does not open a tab when the server has no contents", async () => {
ctrl = new EditorController(createMemoryBackend());
it("navigates C# types and members in one read-only assembly tab", async () => {
const backend = createMemoryBackend({ "/p/Player.cs": "class Player {}\n", [CSHARP]: CSHARP_TEXT });
const save = vi.spyOn(backend, "save");
ctrl = new EditorController(backend);
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);
await ctrl.openFile("/p/Player.cs", { focus: false });
await ctrl.openFile(CSHARP, { selection: { line: 1, col: 13, endLine: 1, endCol: 26 }, focus: false });
const tab = activeTab();
const slot = ctrl.slotFor(tab.id)!;
expect(tab.input).toMatchObject({
kind: "virtual",
path: CSHARP,
data: { title: "UnityEngine.MonoBehaviour.cs", description: "UnityEngine.CoreModule", decompiled: true },
});
expect(slot.model.ctx()).toMatchObject({ language: "csharp", readOnly: true, virtual: true });
const selected = () => {
const state = slot.currentState();
return state.sliceDoc(state.selection.main.from, state.selection.main.to);
};
expect(selected()).toBe("MonoBehaviour");
await ctrl.openFile(CSHARP, { selection: { line: 2, col: 16, endLine: 2, endCol: 28 }, focus: false });
expect(activeTab().id).toBe(tab.id);
expect(selected()).toBe("CancelInvoke");
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(2);
const doc = [...backend.docs.values()].find((d) => d.path === CSHARP)!;
await expect(backend.edit(doc.id, doc.version, [{ from: 0, to: 0, insert: "x" }])).rejects.toMatchObject({
kind: "permissionDenied",
});
await slot.model.save("explicit");
expect(save).not.toHaveBeenCalled();
expect((await backend.snapshot(doc.id)).text).toBe(CSHARP_TEXT);
await ctrl.back();
expect(activeTab().input.path).toBe(CSHARP);
expect(slot.currentState().selection.main.head).toBe(slot.currentState().doc.line(2).from + 13);
await ctrl.back();
expect(activeTab().input.path).toBe("/p/Player.cs");
await ctrl.forward();
expect(activeTab().input.path).toBe(CSHARP);
await ctrl.forward();
expect(slot.currentState().selection.main.head).toBe(slot.currentState().doc.line(3).from + 16);
expect(JSON.stringify(serializeSession(useTabs.getState(), {}))).not.toContain("csharp:");
});
it.each(["jdt://contents/missing.jar/p/X.class", "csharp:/p/App.csproj/decompiled/Missing.cs"])(
"does not open a tab when the server has no contents for %s",
async (uri) => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
await ctrl.openFile(uri, { focus: false });
expect(Object.values(useTabs.getState().panes).flatMap((p) => p.tabs)).toHaveLength(0);
},
);
});
+1
Переглянути файл
@@ -1,5 +1,6 @@
{
"Cannot copy reference": "Cannot copy reference",
"Decompiled assembly (read-only).": "Decompiled assembly (read-only).",
"Connect through:": "Connect through:",
"Connect through": "Connect through",
"GitHub Commit Checks": "GitHub Commit Checks",
+1
Переглянути файл
@@ -4715,6 +4715,7 @@
"Vim rc file": "Vim rc 文件",
"Breakpoints are not supported in output classes without source code.": "输出目录中无源码的类不支持断点。",
"Decompiled .class file: sources are not available.": "已反编译的 .class 文件:没有可用的源代码。",
"Decompiled assembly (read-only).": "已反编译的程序集(只读)。",
"No column of the file matches the table": "文件中没有与该表匹配的列",
"Not a URL of PostgreSQL, MySQL, SQLite or a compatible database": "不是 PostgreSQL、MySQL、SQLite 或兼容数据库的 URL",
"The Database tree shows only the checked names and patterns (* and ?); the connected database always shows.": "数据库树只显示选中的名称和模式(* 和 ?);已连接的数据库始终显示。",