diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 4445ec463..6390eb679 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -797,6 +797,9 @@ impl LspManager { if server.name == "gopls" { params["capabilities"]["experimental"]["interactiveResolve"] = json!({ "inputTypes": ["string"] }); } + if server.name == "csharp-ls" { + params["capabilities"]["experimental"]["csharp"]["metadataUris"] = json!(true); + } if !init_options.is_null() { params["initializationOptions"] = init_options; } @@ -1092,7 +1095,15 @@ impl LspManager { cancel: Option<&CancellationToken>, timeout: Duration, ) -> IdeResult { - let fut = t.run.peer.request::(method, params, cancel.cloned()); + let fut = async { + if t.server.name == "csharp-ls" { + let mut value = t.run.peer.request::(method, params, cancel.cloned()).await?; + virtual_doc::normalize_csharp_uris(&mut value); + serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}"))) + } else { + t.run.peer.request::(method, params, cancel.cloned()).await + } + }; match tokio::time::timeout(timeout, fut).await { Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)), Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)), @@ -2630,7 +2641,14 @@ impl LspManager { }; if let Some((entry, text)) = found { let title = entry.rsplit('/').next().unwrap_or(&entry).to_owned(); - return Ok(self.store_virtual(uri, text, Some(title), &[], self.origin_server(uri).map(|s| s.id))); + return Ok(self.store_virtual( + uri, + text, + Some(title), + None, + &[], + self.origin_server(uri).map(|s| s.id), + )); } } let class_file = scheme == "jdt" || (scheme == "file" && uri.ends_with(".class")); @@ -2641,6 +2659,7 @@ impl LspManager { let mut error = None; for t in &targets { let languages = t.server.languages(); + let mut description = None; let serves_scheme = t.caps().content_schemes().contains(&scheme); let result: IdeResult> = if serves_scheme && !class_file { self.call::<_, Option>( @@ -2655,6 +2674,21 @@ impl LspManager { } else if class_file && languages.iter().any(|l| l.as_str() == "java") { self.call::<_, Option>(t, "java/classFileContents", json!({ "uri": uri }), None, LONG_TIMEOUT) .await + } else if scheme == "csharp" && t.server.name == "csharp-ls" { + self.call::<_, Option>( + t, + "csharp/metadata", + json!({ "textDocument": { "uri": uri } }), + None, + LONG_TIMEOUT, + ) + .await + .map(|metadata| { + metadata.map(|m| { + description = m.assembly_name.filter(|name| !name.is_empty()); + m.source + }) + }) } else if jar_class && t.caps().commands().iter().any(|c| c == "decompile") { // The official Kotlin LSP: `decompile` answers `{ code, language }`. self.call::<_, Option>( @@ -2685,7 +2719,7 @@ impl LspManager { continue; } }; - return Ok(self.store_virtual(uri, text, None, &languages, Some(t.server.id))); + return Ok(self.store_virtual(uri, text, None, description, &languages, Some(t.server.id))); } if jar_class && error.is_none() { return Err(IdeError::not_found(format!( @@ -2702,10 +2736,15 @@ impl LspManager { uri: &str, text: String, title: Option, + description: Option, languages: &[LanguageId], server: Option, ) -> VirtualDocument { - let decompiled = virtual_doc::is_decompiled(&text); + let decompiled = if virtual_doc::scheme(uri) == "csharp" { + virtual_doc::is_csharp_decompiled(uri) + } else { + virtual_doc::is_decompiled(&text) + }; let title = title.unwrap_or_else(|| virtual_doc::title(uri, decompiled)); let detected = self.languages.detect(Path::new(&title), None); let language_id = if detected == LanguageId::plain_text() { @@ -2718,7 +2757,7 @@ impl LspManager { text, language_id, title, - description: virtual_doc::description(uri), + description: description.or_else(|| virtual_doc::description(uri)), decompiled, }; if let Some(server) = server { diff --git a/crates/ide-lsp/src/pull.rs b/crates/ide-lsp/src/pull.rs index 63016c0ee..819ba88cc 100644 --- a/crates/ide-lsp/src/pull.rs +++ b/crates/ide-lsp/src/pull.rs @@ -5,7 +5,7 @@ //! and shown together. use crate::server::{Running, Server}; -use crate::{LspManager, convert}; +use crate::{LspManager, convert, virtual_doc}; use ide_core::{DocId, IdeEvent}; use ide_jsonrpc::RpcError; use lsp_types as lsp; @@ -43,6 +43,10 @@ impl LspManager { let targets: Vec = { let docs = self.docs.lock(); let Some(d) = docs.get(&doc) else { return }; + // Decompiled C# is a source view, not compilable project code (e.g. Unity's older language version). + if virtual_doc::is_csharp_decompiled(d.uri.as_str()) { + return; + } d.servers .iter() .filter(|a| only.is_none_or(|s| s.id == a.server.id)) diff --git a/crates/ide-lsp/src/virtual_doc.rs b/crates/ide-lsp/src/virtual_doc.rs index f05354132..1faca595d 100644 --- a/crates/ide-lsp/src/virtual_doc.rs +++ b/crates/ide-lsp/src/virtual_doc.rs @@ -2,7 +2,68 @@ //! URI (library classes from `jdt://`, disk classes from `file://`, or //! `workspace/textDocumentContent`). -use percent_encoding::percent_decode_str; +use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode}; + +const URI_ESCAPES: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'<') + .add(b'>') + .add(b'[') + .add(b']') + .add(b'\\') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + +/// csharp-ls uses .NET Uri.ToString(), which can leave spaces and Unicode unescaped. +/// Normalize only document URI fields, before lsp-types' strict URI deserialization. +pub(crate) fn normalize_csharp_uris(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => values.iter_mut().for_each(normalize_csharp_uris), + serde_json::Value::Object(fields) => { + for (key, value) in fields { + // Opaque payloads must survive resolve/execute round trips unchanged. + if matches!(key.as_str(), "data" | "arguments") { + continue; + } + if matches!(key.as_str(), "uri" | "targetUri") + && let Some(uri) = value.as_str().filter(|s| s.starts_with("csharp:")) + { + *value = utf8_percent_encode(uri, URI_ESCAPES).to_string().into(); + } else { + normalize_csharp_uris(value); + } + } + } + _ => {} + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CSharpMetadata { + pub source: String, + pub assembly_name: Option, +} + +/// csharp-ls serves both decompiled and source-generated documents through metadata. +pub(crate) fn is_csharp_decompiled(uri: &str) -> bool { + if scheme(uri) != "csharp" { + return false; + } + let segs = segments(uri); + // The kind follows the project file, not a directory that happens to have this name. + if let Some(kind) = segs.windows(2).find_map(|s| { + (s[0].ends_with(".csproj") && matches!(s[1].as_str(), "decompiled" | "generated")).then_some(s[1].as_str()) + }) { + return kind == "decompiled"; + } + // Older csharp-ls versions used csharp:/metadata/... for decompiled symbols. + segs.first().is_some_and(|s| s == "metadata") +} /// Recognize decompiler headers, without treating disassembler stubs as Java source. pub fn is_decompiled(text: &str) -> bool { @@ -107,6 +168,48 @@ mod tests { const JDT: &str = "jdt://contents/spring-boot-4.1.1.jar/org.springframework.boot/SpringApplication.class?=spring-demo/%5C/Users%5C/me%5C/.m2%5C/repository%5C/spring-boot-4.1.1.jar%3Corg.springframework.boot%28SpringApplication.class"; + #[test] + fn csharp_metadata_titles_and_kinds() { + for (uri, expected) in [ + ("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true), + ("csharp:/home/me/%E9%A1%B9%E7%9B%AE/App.csproj/decompiled/System.String.cs", true), + ("csharp:/metadata/projects/App/assemblies/System.Runtime/symbols/System.String.cs", true), + ("csharp:/work/App.csproj/generated/Generator/Player.g.cs", false), + ("csharp:/work/decompiled/App.csproj/generated/Generator/Player.g.cs", false), + ("csharp:/metadata/App.csproj/generated/Player.g.cs", false), + ("csharp:/work/App.csproj/generated/decompiled/Player.g.cs", false), + ("file:///work/App.csproj/decompiled/Player.cs", false), + ] { + assert_eq!(is_csharp_decompiled(uri), expected, "{uri}"); + } + assert_eq!( + title("csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs", true), + "UnityEngine.MonoBehaviour.cs" + ); + assert_eq!(title("csharp:/home/me/App.csproj/decompiled/Example.%E7%B1%BB.cs", true), "Example.类.cs"); + } + + #[test] + fn csharp_uri_encoding_preserves_escapes_and_other_response_fields() { + let uri = "csharp:/D:/My Game/项目 [1]/App%23%25.csproj/decompiled/Example.C%601.cs"; + let encoded = "csharp:/D:/My%20Game/%E9%A1%B9%E7%9B%AE%20%5B1%5D/App%23%25.csproj/decompiled/Example.C%601.cs"; + let mut response = serde_json::json!([ + { "uri": uri, "source": uri }, + { "location": { "targetUri": uri } }, + { "uri": encoded }, + { "uri": "file:///D:/project/App.cs" }, + { "uri": "jdt://contents/a.jar/p/C.class?=p/%5C/a.jar" }, + { "data": { "uri": uri }, "command": { "arguments": [{ "targetUri": uri }] } } + ]); + let original = response.clone(); + normalize_csharp_uris(&mut response); + assert_eq!(response[0]["uri"], encoded); + assert_eq!(response[0]["source"], uri); + assert_eq!(response[1]["location"]["targetUri"], encoded); + assert_eq!(response.as_array().unwrap()[2..], original.as_array().unwrap()[2..]); + assert!(encoded.parse::().is_ok()); + } + #[test] fn jdt_titles() { assert_eq!(title(JDT, true), "SpringApplication.class"); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index fca3e4231..b7c1f94fb 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2253,6 +2253,106 @@ async fn noop_handler_is_usable_for_raw_peers() { // ------------------------------------------------------------------ virtual documents +#[tokio::test(flavor = "multi_thread")] +async fn csharp_metadata_navigation_and_generated_documents() { + let mut caps = incremental_caps(); + caps["diagnosticProvider"] = json!({ "interFileDependencies": false, "workspaceDiagnostics": false }); + let h = harness_for("csharp", "cs", &[("other-csharp", incremental_caps()), ("csharp-ls", caps)], &[]); + let (doc, path) = h.open("Player.cs", "class Player : MonoBehaviour {}\n"); + h.wait_status("other-csharp", ServerStatus::Ready).await; + h.wait_status("csharp-ls", ServerStatus::Ready).await; + let s = h.launcher.state("csharp-ls"); + let other = h.launcher.state("other-csharp"); + eventually(|| !s.requested("textDocument/diagnostic").is_empty()).await; + assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"]["csharp"]["metadataUris"], true); + assert!(other.requested("initialize")[0]["capabilities"]["experimental"]["csharp"].is_null()); + + let uri = "csharp:/D:/My%20Game/Game.csproj/decompiled/UnityEngine.MonoBehaviour.cs"; + let text = "namespace UnityEngine;\npublic class MonoBehaviour {\n public void CancelInvoke() {}\n}\n"; + s.respond("csharp/metadata", json!({ "assemblyName": "UnityEngine.CoreModule", "source": text })); + // Both Location and LocationLink preserve the server's type/member selection. + for (response, range) in [ + ( + json!({ "uri": uri.replace("%20", " "), "range": {"start":{"line":1,"character":13},"end":{"line":1,"character":26}} }), + rng(1, 13, 1, 26), + ), + ( + json!([{ "targetUri": uri, "targetRange": {"start":{"line":0,"character":0},"end":{"line":3,"character":1}}, "targetSelectionRange": {"start":{"line":2,"character":16},"end":{"line":2,"character":28}} }]), + rng(2, 16, 2, 28), + ), + ] { + s.respond("textDocument/definition", response); + let defs = h.mgr.goto(doc, Pos::new(0, 16), GotoKind::Definition).await.unwrap(); + assert_eq!(defs, vec![ide_core::Location { path: uri.into(), range, uri: Some(uri.into()) }]); + let vd = h.mgr.virtual_document(uri).await.unwrap(); + assert_eq!(vd.text, text); + assert_eq!(vd.title, "UnityEngine.MonoBehaviour.cs"); + assert_eq!(vd.language_id.as_str(), "csharp"); + assert_eq!(vd.description.as_deref(), Some("UnityEngine.CoreModule")); + assert!(vd.decompiled); + } + assert_eq!(s.requested("csharp/metadata"), vec![json!({"textDocument":{"uri":uri}})]); + assert!(other.requested("csharp/metadata").is_empty()); + + let vdoc = DocId::next(); + h.mgr.did_open_virtual(vdoc, uri, &h.lang, 0, text); + eventually(|| s.docs.lock().contains_key(uri)).await; + s.respond("textDocument/hover", json!({"contents":"class MonoBehaviour"})); + h.mgr.hover(vdoc, Pos::new(1, 13), Default::default()).await.unwrap(); + assert_eq!(s.requested("textDocument/hover").last().unwrap()["textDocument"]["uri"], uri); + assert!(other.requested("textDocument/hover").is_empty()); + h.mgr.did_close(vdoc); + eventually(|| !s.docs.lock().contains_key(uri)).await; + assert!(s.notified("textDocument/didChange").is_empty()); + + // Generated sources use the same request but are not decompiled, even under a directory named decompiled. + let generated = "csharp:/work/decompiled/Game.csproj/generated/Generator/Player.g.cs"; + s.respond("csharp/metadata", json!({"source":"partial class Player {}"})); + let vd = h.mgr.virtual_document(generated).await.unwrap(); + assert_eq!(vd.title, "Player.g.cs"); + assert_eq!(vd.language_id.as_str(), "csharp"); + assert!(vd.description.is_none()); + assert!(!vd.decompiled); + // Generated and ordinary source still get diagnostics; decompiled assemblies do not. + let generated_doc = DocId::next(); + h.mgr.did_open_virtual(generated_doc, generated, &vd.language_id, 0, &vd.text); + eventually(|| s.requested("textDocument/diagnostic").iter().any(|p| p["textDocument"]["uri"] == generated)).await; + assert!(s.requested("textDocument/diagnostic").iter().all(|p| p["textDocument"]["uri"] != uri)); + h.mgr.did_close(generated_doc); + + // Ordinary definitions still resolve to a disk path. + s.respond( + "textDocument/definition", + json!({"uri":uri_of(&path),"range":{"start":{"line":0,"character":6},"end":{"line":0,"character":12}}}), + ); + let defs = h.mgr.goto(doc, Pos::new(0, 6), GotoKind::Definition).await.unwrap(); + assert_eq!(defs[0].path, path); + assert!(defs[0].uri.is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn csharp_metadata_failures_can_be_retried() { + let h = harness_for("csharp", "cs", &[("csharp-ls", incremental_caps())], &[]); + h.open("App.cs", "class App {}\n"); + h.wait_status("csharp-ls", ServerStatus::Ready).await; + let s = h.launcher.state("csharp-ls"); + let uri = "csharp:/work/App.csproj/decompiled/System.String.cs"; + for response in [Value::Null, json!({"source":""})] { + s.respond("csharp/metadata", response); + let err = h.mgr.virtual_document(uri).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound); + assert!(err.message.contains("System.String.cs")); + } + s.errors.lock().insert("csharp/metadata".into(), (-32603, "decompilation failed".into())); + assert!(h.mgr.virtual_document(uri).await.unwrap_err().message.contains("decompilation failed")); + s.errors.lock().remove("csharp/metadata"); + s.respond("csharp/metadata", json!({"source":"public class String {}", "assemblyName":""})); + let vd = h.mgr.virtual_document(uri).await.unwrap(); + assert!(vd.decompiled); + assert!(vd.description.is_none()); + assert_eq!(s.requested("csharp/metadata").len(), 4); +} + #[tokio::test(flavor = "multi_thread")] async fn virtual_locations_and_text_document_content() { let mut caps = incremental_caps(); diff --git a/src/editor/core/backend.ts b/src/editor/core/backend.ts index e75ce8d86..6a687012e 100644 --- a/src/editor/core/backend.ts +++ b/src/editor/core/backend.ts @@ -126,6 +126,7 @@ const EXT_LANG: Record = { kt: "kotlin", kts: "kotlin", java: "java", + cs: "csharp", py: "python", gd: "gdscript", go: "go", diff --git a/src/editor/core/view/Banner.tsx b/src/editor/core/view/Banner.tsx index 963665f22..5cdbffde5 100644 --- a/src/editor/core/view/Banner.tsx +++ b/src/editor/core/view/Banner.tsx @@ -43,7 +43,11 @@ export function VirtualBanner({
- {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)}`} {description && ( diff --git a/src/editor/core/view/banner.test.tsx b/src/editor/core/view/banner.test.tsx index f11109139..f1ab46139 100644 --- a/src/editor/core/view/banner.test.tsx +++ b/src/editor/core/view/banner.test.tsx @@ -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(); + 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), diff --git a/src/editor/core/virtual.test.ts b/src/editor/core/virtual.test.ts index aaa464afa..52ae72482 100644 --- a/src/editor/core/virtual.test.ts +++ b/src/editor/core/virtual.test.ts @@ -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(); return { ...actual, virtualDocument: vi.fn(async (uri: string): Promise => { + 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); + }, + ); }); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index a19672eb9..a141a7e5a 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 844c54d36..29aab6835 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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.": "数据库树只显示选中的名称和模式(* 和 ?);已连接的数据库始终显示。",