diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 346049bc4..c4263186b 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -16,6 +16,8 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +pub mod sources; + /// Phases shown under "Lifecycle" (IDEA's default list). pub const LIFECYCLE: &[&str] = &["clean", "validate", "compile", "test", "package", "verify", "install", "site", "deploy"]; diff --git a/crates/ide-build/src/maven/sources.rs b/crates/ide-build/src/maven/sources.rs new file mode 100644 index 000000000..f72a924c1 --- /dev/null +++ b/crates/ide-build/src/maven/sources.rs @@ -0,0 +1,124 @@ +//! On-demand sources resolution using the project's Maven launcher and repositories. + +use super::MavenProject; +use crate::process; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, PartialEq, Eq)] +pub struct Artifact { + pub group: String, + pub name: String, + pub version: String, + pub sources: PathBuf, +} + +/// Resolve only jars inside the selected local repository, including custom repositories. +pub fn artifact(jar: &Path, repository: &Path) -> Option { + let relative = jar.strip_prefix(repository).ok()?; + let parts: Vec<&str> = relative + .components() + .map(|c| match c { + Component::Normal(s) => s.to_str(), + _ => None, + }) + .collect::>()?; + if parts.len() < 4 { + return None; + } + let n = parts.len(); + let (name, version, file) = (parts[n - 3], parts[n - 2], parts[n - 1]); + let valid = |s: &str| { + !s.is_empty() && s != "." && s != ".." && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c)) + }; + if !parts[..n - 1].iter().all(|s| valid(s)) || file != format!("{name}-{version}.jar") { + return None; + } + Some(Artifact { + group: parts[..n - 3].join("."), + name: name.into(), + version: version.into(), + sources: jar.with_file_name(format!("{name}-{version}-sources.jar")), + }) +} + +pub fn download( + root: &Path, + project: &MavenProject, + artifact: &Artifact, + repository: &Path, + cancel: &CancellationToken, +) -> IdeResult> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if artifact.sources.is_file() { + return Ok(Some(artifact.sources.clone())); + } + let mut args = vec!["-B".into(), "-N".into(), "-Dstyle.color=never".into()]; + args.extend(project.options.clone()); + args.extend([ + format!("-Dmaven.repo.local={}", repository.display()), + "org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get".into(), + format!("-Dartifact={}:{}:{}:jar:sources", artifact.group, artifact.name, artifact.version), + "-Dtransitive=false".into(), + ]); + let output = process::run(&project.command, &args, &root.join(&project.dir), &project.env, cancel, |_, _| {})?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if artifact.sources.is_file() { + return Ok(Some(artifact.sources.clone())); + } + let coordinate = format!("{}:{}:jar:sources:{}", artifact.group, artifact.name, artifact.version); + if output.success + || output.text.lines().any(|line| { + line.contains(&coordinate) && (line.contains("Could not find artifact") || line.contains("was not found")) + }) + { + return Ok(None); + } + Err(IdeError::unavailable( + output.text.lines().rev().take(20).collect::>().into_iter().rev().collect::>().join("\n"), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_sources_coordinates_from_maven_layouts() { + for repository in ["/home/me/.m2/repository", "/tmp/project repo", "C:/Users/me/.m2/repository"] { + for (group, name, version) in [ + ("org/apache/commons", "commons-lang3", "3.17.0"), + ("com/example", "example-lib", "1.0-SNAPSHOT"), + ("single", "lib", "1.0.0-rc.1"), + ] { + let repository = Path::new(repository); + let dir = repository.join(group).join(name).join(version); + let found = artifact(&dir.join(format!("{name}-{version}.jar")), repository).unwrap(); + assert_eq!(found.group, group.replace('/', ".")); + assert_eq!(found.name, name); + assert_eq!(found.version, version); + assert_eq!(found.sources, dir.join(format!("{name}-{version}-sources.jar"))); + } + } + } + + #[test] + fn rejects_non_artifacts_and_paths_outside_the_repository() { + let repository = Path::new("/m2/repository"); + for path in [ + "/m2/repository-other/g/a/1/a-1.jar", + "/m2/repository/g/a/1/a-1-sources.jar", + "/m2/repository/g/a/1/other.jar", + "/m2/repository/g/a/../a-1.jar", + "/m2/repository/g/a/1/a-1.pom", + "/m2/repository/g/a-1.jar", + "/gradle/caches/modules-2/files-2.1/g/a/1/hash/a-1.jar", + ] { + assert_eq!(artifact(Path::new(path), repository), None, "{path}"); + } + } +} diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index b13117653..dc80652bc 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -585,6 +585,82 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide host.project(window)?.lsp.virtual_document(&uri).await } +/// Whether this class belongs to the project's Maven local repository. +pub async fn lsp_can_download_sources(host: &Host, window: &str, uri: String) -> IdeResult { + let project = host.project(window)?; + if !project.is_trusted() { + return Ok(false); + } + let Some(jar) = ide_lsp::LspManager::library_archive(&uri) else { return Ok(false) }; + host.blocking(move || { + let settings = project.settings.effective(); + let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings) + .or_else(ide_build::maven::default_local_repo); + Ok(repository.is_some_and(|repo| ide_build::maven::sources::artifact(&jar, &repo).is_some()) + && ide_build::maven::has_projects(&project.root, &settings.languages.java.maven)) + }) + .await +} + +/// Download with the project's Maven configuration, or attach an archive selected by the user. +pub async fn lsp_library_sources( + host: &Host, + window: &str, + request: RequestId, + uri: String, + pos: Pos, + sources: Option, +) -> IdeResult> { + host.require_trust(window)?; + let project = host.project(window)?; + let cancel = host.begin_project_request(window, request); + let result = async { + let sources = match sources { + Some(sources) => Some(sources), + None => { + let jar = ide_lsp::LspManager::library_archive(&uri) + .ok_or_else(|| IdeError::invalid("not a library class"))?; + let project = project.clone(); + let cancel = cancel.clone(); + host.blocking(move || { + let settings = project.settings.effective(); + let repository = ide_sdk::apply::maven_local_repository(&project.root, &settings) + .or_else(ide_build::maven::default_local_repo) + .ok_or_else(|| IdeError::unavailable("Maven local repository is unavailable"))?; + let artifact = ide_build::maven::sources::artifact(&jar, &repository) + .ok_or_else(|| IdeError::invalid("not an artifact in the Maven local repository"))?; + let model = super::build::load(&project.root, &project.build_cache_dir(), &settings); + let maven = model.maven.ok_or_else(|| IdeError::unavailable("no Maven project"))?; + let owns = |p: &&ide_build::maven::MavenProject| { + p.dependencies.iter().any(|d| d.group_id == artifact.group && d.artifact_id == artifact.name) + }; + let mut owner = maven + .projects + .iter() + .find(owns) + .or_else(|| maven.projects.first()) + .cloned() + .ok_or_else(|| IdeError::unavailable("no Maven project"))?; + let profiles = + settings.languages.java.maven.profiles(&ide_build::maven_profiles(&project.build_cache_dir())); + if !profiles.is_empty() { + owner.options.push(format!("-P{}", profiles.join(","))); + } + ide_build::maven::sources::download(&project.root, &owner, &artifact, &repository, &cancel) + }) + .await? + } + }; + match sources { + Some(sources) => project.lsp.library_source_location(&uri, sources, pos, &cancel).await, + None => Ok(None), + } + } + .await; + host.end_request(request); + result +} + /// Opens compiled classes through JDT, with project source taking priority on disk. pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::PathBuf) -> IdeResult { let archive = ide_vfs::split_archive_path(&path); @@ -1649,6 +1725,14 @@ pub(crate) fn register(d: &mut Dispatcher) { |c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await }, ); d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await }); + d.add( + "lsp_can_download_sources", + |c| async move { lsp_can_download_sources(&c.host, &c.window, c.arg("uri")?).await }, + ); + d.add("lsp_library_sources", |c| async move { + lsp_library_sources(&c.host, &c.window, c.arg("request")?, c.arg("uri")?, c.arg("pos")?, c.arg("sources")?) + .await + }); d.add("lsp_references", |c| async move { let on_partial = c.sender("onPartial")?; lsp_references( diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index c6b049db5..a2e4261a1 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -2952,6 +2952,64 @@ impl LspManager { .cloned() } + /// The binary archive behind a library document, never its displayed tab title. + pub fn library_archive(uri: &str) -> Option { + if virtual_doc::scheme(uri) == "jar" { + return library_source::parse(uri).map(|e| e.jar); + } + virtual_doc::description(uri).filter(|p| p.ends_with(".jar") && Path::new(p).is_absolute()).map(PathBuf::from) + } + + /// Open an attached archive at the declaration under the decompiled editor's caret. + pub async fn library_source_location( + &self, + uri: &str, + sources: PathBuf, + pos: Pos, + cancel: &CancellationToken, + ) -> IdeResult> { + let previous = tokio::select! { + result = self.virtual_document(uri) => result?, + _ = cancel.cancelled() => return Err(IdeError::cancelled()), + }; + let name = previous + .text + .lines() + .nth(pos.line as usize) + .and_then(|line| library_source::identifier_at(line, pos.col)) + .map(str::to_owned); + let original = uri.to_owned(); + let archive = sources.clone(); + let found = tokio::task::spawn_blocking(move || { + library_source::source_in_archive(&original, &archive).map(|(uri, text)| { + let range = name.as_deref().map(|n| library_source::declaration_range(&text, n)).unwrap_or_default(); + Location { path: uri.clone().into(), uri: Some(uri), range } + }) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if found.is_some() { + if virtual_doc::scheme(uri) == "jdt" { + let result = self + .jdtls_command_cancellable( + &self.project_root, + "java.project.updateSourceAttachment", + vec![json!({ "classFileUri": uri, "attributes": { "sourceAttachmentPath": sources } })], + cancel, + ) + .await?; + if let Some(error) = result.get("errorMessage").and_then(Value::as_str).filter(|s| !s.is_empty()) { + return Err(IdeError::unavailable(error)); + } + } + self.virtual_docs.lock().remove(uri); + } + Ok(found) + } + /// Contents of a virtual document, from the server that owns `uri`: /// `workspace/textDocumentContent` (LSP 3.18) when the server serves the /// scheme, jdtls' `java/classFileContents` for `jdt:` URIs. A `jar:` entry diff --git a/crates/ide-lsp/src/library_source.rs b/crates/ide-lsp/src/library_source.rs index 50f91a441..0631f14d0 100644 --- a/crates/ide-lsp/src/library_source.rs +++ b/crates/ide-lsp/src/library_source.rs @@ -79,6 +79,27 @@ pub(crate) fn class_source(uri: &str) -> Option<(String, String)> { None } +pub(crate) fn source_in_archive(uri: &str, sources: &Path) -> Option<(String, String)> { + let jar = if uri.starts_with("jdt://contents/") { + let path = uri.split('?').next()?.strip_prefix("jdt://contents/")?; + let parts: Vec<_> = path.split('/').collect(); + let [_, package, file] = parts.as_slice() else { return None }; + let file = percent_decode_str(file).decode_utf8().ok()?; + let name = file.strip_suffix(".class").or_else(|| file.strip_suffix(".java"))?; + let package = percent_decode_str(package).decode_utf8().ok()?.replace('.', "/"); + JarEntry { + prefix: "jar:file://".into(), + jar: sources.to_owned(), + entry: if package.is_empty() { format!("{name}.class") } else { format!("{package}/{name}.class") }, + } + } else { + parse(uri)? + }; + let class = jar.entry.strip_suffix(".class")?; + let (entry, text) = source_entry(sources, class)?; + Some((jar.uri_of(sources, &entry), text)) +} + /// Sources jars of the artifact `jar`: `-sources.jar` next to it (Maven /// repository), or in a sibling hash folder (Gradle `files-2.1/////`). fn sources_jars(jar: &Path) -> Vec { @@ -139,7 +160,20 @@ pub(crate) fn declaration_range(text: &str, name: &str) -> Range { const KEYWORDS: [&str; 9] = ["fun", "class", "interface", "object", "val", "var", "typealias", "enum", "record"]; let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$'; let mut fallback = None; + let mut block_comment = false; for (line_no, line) in text.lines().enumerate() { + let trimmed = line.trim_start(); + if block_comment { + block_comment = !trimmed.contains("*/"); + continue; + } + if trimmed.starts_with("/*") { + block_comment = !trimmed.contains("*/"); + continue; + } + if trimmed.starts_with("//") || trimmed.starts_with('*') { + continue; + } let mut from = 0; while let Some(i) = line[from..].find(name) { let start = from + i; @@ -161,8 +195,11 @@ pub(crate) fn declaration_range(text: &str, name: &str) -> Range { if words.iter().any(|w| KEYWORDS.contains(w)) { return range; } - let java_member = - after == Some('(') && !head.trim_end().ends_with('.') && !head.trim().is_empty() && !head.contains('='); + let java_member = after == Some('(') + && !head.trim_end().ends_with('.') + && !head.trim().is_empty() + && !head.contains(['=', '(']) + && !words.iter().any(|w| matches!(*w, "return" | "throw" | "new" | "yield" | "assert")); if java_member && fallback.is_none() { fallback = Some(range); } @@ -227,6 +264,24 @@ mod tests { assert_eq!(parse("jar:///no/entry.jar"), None); } + #[test] + fn opens_selected_archives_for_jdt_and_jar_classes() { + let tmp = tempfile::tempdir().unwrap(); + let sources = tmp.path().join("library sources.zip"); + jar(&sources, &[("p/Lib.java", "package p; public class Lib { class Inner {} }")]); + for uri in [ + "jdt://contents/lib-1.jar/p/Lib.class?=project/escaped-jar-path", + "jdt://contents/lib-1.jar/p/Lib.java?=project/escaped-jar-path", + "jar:file:///m2/g/lib/1/lib-1.jar!/p/Lib$Inner.class", + ] { + let (uri, text) = source_in_archive(uri, &sources).unwrap(); + assert!(uri.ends_with("library%20sources.zip!/p/Lib.java"), "{uri}"); + assert!(text.contains("class Lib")); + } + assert!(source_in_archive("jdt://contents/lib.jar/p/Missing.class", &sources).is_none()); + assert!(source_in_archive("csharp:/p/App.csproj/decompiled/Lib.cs", &sources).is_none()); + } + #[test] fn finds_the_source_of_each_kind_of_class_in_maven_and_gradle_caches() { let tmp = tempfile::tempdir().unwrap(); @@ -285,6 +340,8 @@ mod tests { assert_eq!(at(java, "run"), (3, 49, 52), "the member, not the call in its body"); assert_eq!(at("val x = 1", "missing"), (0, 0, 0)); assert_eq!(at("// é\nfun ü() {}", "ü"), (1, 4, 5), "UTF-16 columns"); + let java = "class Lib {\n boolean caller(String s) { return isBlank(s); }\n /**\n * Changed signature from isBlank(String) to isBlank(CharSequence).\n */\n public static boolean isBlank(CharSequence cs) { return false; }\n}\n"; + assert_eq!(at(java, "isBlank"), (5, 26, 33), "the declaration, not a call or Javadoc signature"); } #[test] diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index baf2cdc6f..077a778fc 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -124,6 +124,25 @@ pub async fn lsp_virtual_document( ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await } +#[tauri::command] +#[specta::specta] +pub async fn lsp_can_download_sources(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult { + ide_host::ops::lsp::lsp_can_download_sources(&state.host, window.label(), uri).await +} + +#[tauri::command] +#[specta::specta] +pub async fn lsp_library_sources( + window: Window, + state: State<'_, AppState>, + request: RequestId, + uri: String, + pos: Pos, + sources: Option, +) -> IdeResult> { + ide_host::ops::lsp::lsp_library_sources(&state.host, window.label(), request, uri, pos, sources).await +} + #[tauri::command] #[specta::specta] pub async fn lsp_resolve_class_file( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e76c72980..d4c302fe6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -240,6 +240,8 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_implementations, lsp::lsp_goto_implementation, lsp::lsp_virtual_document, + lsp::lsp_can_download_sources, + lsp::lsp_library_sources, lsp::lsp_resolve_class_file, lsp::lsp_references, lsp::lsp_document_highlights, diff --git a/src/bindings.ts b/src/bindings.ts index 40a0aa667..8a9c12096 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -456,6 +456,17 @@ export const commands = { * classes and disk class files. Cached per project. */ lspVirtualDocument: (uri: string) => typedError(__TAURI_INVOKE("lsp_virtual_document", { uri })), + lspCanDownloadSources: (uri: string) => typedError(__TAURI_INVOKE("lsp_can_download_sources", { uri })), + lspLibrarySources: (request: RequestId, uri: string, pos: Pos, sources: string | null) => typedError<{ + /** + * File path; for virtual targets the URI itself (equal to `uri`), which + * the UI opens as a read-only virtual document. + */ + path: IpcPath, + range: Range, + /** Set for non-`file:` targets, e.g. `jdt://contents/…`. */ + uri?: string | null, +} | null, IdeError>(__TAURI_INVOKE("lsp_library_sources", { request, uri, pos, sources })), lspResolveClassFile: (path: string) => typedError(__TAURI_INVOKE("lsp_resolve_class_file", { path })), /** * References of the symbol at `pos`. `project_wide` also searches project files diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index ba9b0e331..6dadc954d 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -16,6 +16,7 @@ import { editorController } from "./controller"; import { attachDocument } from "./model/attachment"; import { previewExtensions } from "./cm/preview"; import { registerEditorActions } from "./actions"; +import { registerLibrarySourcesActions } from "./librarySources"; import { installColorScheme } from "./colorSchemeRuntime"; import { documentBanner } from "./view/Banner"; import { EditorAreaView } from "./view/EditorArea"; @@ -71,6 +72,7 @@ export function activate(): void | (() => void) { installColorScheme(), provideEditorArea(EditorAreaView), registerEditorActions(), + registerLibrarySourcesActions(), registerPasteHistoryActions(), registerEditorBanner(documentBanner), registerOverlay({ id: "editor.gotoLine", component: GotoLineDialog }), diff --git a/src/editor/core/librarySources.test.tsx b/src/editor/core/librarySources.test.tsx new file mode 100644 index 000000000..5dd400152 --- /dev/null +++ b/src/editor/core/librarySources.test.tsx @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { executeAction } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import type { EditorService } from "@/workbench/contributions"; +import * as contributions from "@/workbench/contributions"; +import { invalidateVirtualDocument } from "@/platform/virtualDocs"; +import { registerLibrarySourcesActions } from "./librarySources"; +import { VirtualBanner } from "./view/Banner"; + +const ipc = vi.hoisted(() => ({ support: vi.fn(), sources: vi.fn(), cancel: vi.fn(), choose: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + commands: { lspCanDownloadSources: ipc.support, lspLibrarySources: ipc.sources, cancelRequest: ipc.cancel }, +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: ipc.choose })); +vi.mock("@/platform/virtualDocs", async (original) => ({ + ...(await original()), + invalidateVirtualDocument: vi.fn(), +})); + +const path = "jdt://contents/lib-1.jar/p/Lib.class?=demo/escaped-jar-path"; +let active = path; +const openFile = vi.fn(async (p: string) => { + active = p; +}); +const closePath = vi.fn(async () => true); +let off: () => void; + +beforeEach(() => { + ipc.support.mockResolvedValue({ status: "ok", data: true }); + ipc.cancel.mockResolvedValue(undefined); + active = path; + useProject.setState({ project: { root: "/p", trusted: true } as never }); + vi.spyOn(contributions, "maybeEditorService").mockReturnValue({ + activePath: () => active, + activeCursor: () => ({ path, line: 8, col: 12 }), + openPaths: () => [path], + openFile, + closePath, + } as unknown as EditorService); + off = registerLibrarySourcesActions(); +}); + +afterEach(() => { + off(); + useProject.setState({ project: null }); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +const banner = (uri = path, decompiled = true) => + render(); + +it("downloads with progress, cancellation and the declaration position, then replaces the decompiled tab", async () => { + let finish!: (value: unknown) => void; + ipc.sources.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + banner(); + fireEvent.click(await screen.findByRole("button", { name: "Download Sources" })); + await waitFor(() => expect(ipc.sources).toHaveBeenCalled()); + expect(screen.getByText("Loading library sources…")).toBeTruthy(); + const request = ipc.sources.mock.calls[0]![0]; + expect(ipc.sources).toHaveBeenCalledWith(request, path, { line: 8, col: 12 }, null); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(ipc.cancel).toHaveBeenCalledWith(request)); + const source = "jar:file:///m2/lib-1-sources.jar!/p/Lib.java"; + await act(async () => finish({ status: "ok", data: { path: source, range: { start: { line: 24, col: 5 } } } })); + await waitFor(() => expect(openFile).toHaveBeenCalledWith(source, { selection: { line: 24, col: 5 } })); + expect(closePath).toHaveBeenCalledWith(path); + expect(invalidateVirtualDocument).toHaveBeenCalledWith(path); +}); + +it("keeps the decompiled view and offers a retry when sources do not exist", async () => { + ipc.sources.mockResolvedValue({ status: "ok", data: null }); + banner(); + fireEvent.click(await screen.findByRole("button", { name: "Download Sources" })); + expect(await screen.findByText("Sources not found")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Download Sources" })).toBeTruthy(); + expect(openFile).not.toHaveBeenCalled(); +}); + +it("shows download errors and clears progress", async () => { + const notify = vi.fn(); + vi.spyOn(contributions, "notifications").mockReturnValue({ notify } as never); + ipc.sources.mockResolvedValue({ status: "error", error: { kind: "unavailable", message: "repository offline" } }); + banner(); + fireEvent.click(await screen.findByRole("button", { name: "Download Sources" })); + expect(await screen.findByText("Cannot load library sources")).toBeTruthy(); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("repository offline") })); + expect(screen.queryByText("Loading library sources…")).toBeNull(); +}); + +it("does not offer JVM actions on source or assembly documents", () => { + const first = banner("jdt://contents/lib.jar/p/Lib.class", false); + expect(screen.queryByRole("button")).toBeNull(); + first.unmount(); + banner("csharp:/p/App.csproj/decompiled/Lib.cs"); + expect(screen.queryByRole("button")).toBeNull(); + expect(ipc.support).not.toHaveBeenCalled(); +}); + +it("chooses an archive for a library without a supported download", async () => { + ipc.support.mockResolvedValue({ status: "ok", data: false }); + ipc.choose.mockResolvedValue("/tmp/library-sources.zip"); + ipc.sources.mockResolvedValue({ status: "ok", data: null }); + banner(); + fireEvent.click(screen.getByRole("button", { name: "Choose Sources…" })); + await waitFor(() => + expect(ipc.sources).toHaveBeenCalledWith( + expect.any(Number), + path, + { line: 8, col: 12 }, + "/tmp/library-sources.zip", + ), + ); + expect(screen.queryByRole("button", { name: "Download Sources" })).toBeNull(); +}); + +it("cancels on project change and ignores the previous project's result", async () => { + let finish!: (value: unknown) => void; + ipc.sources.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const running = executeAction("editor.downloadSources", path); + await waitFor(() => expect(ipc.sources).toHaveBeenCalled()); + act(() => useProject.setState({ project: null })); + expect(ipc.cancel).toHaveBeenCalled(); + finish({ status: "ok", data: null }); + await running; + expect(openFile).not.toHaveBeenCalled(); +}); diff --git a/src/editor/core/librarySources.ts b/src/editor/core/librarySources.ts new file mode 100644 index 000000000..4fcf8ef4c --- /dev/null +++ b/src/editor/core/librarySources.ts @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { create } from "zustand"; +import { registerActions } from "@/platform/actions"; +import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { useProject } from "@/platform/project"; +import { invalidateVirtualDocument } from "@/platform/virtualDocs"; +import { maybeEditorService, notifications } from "@/workbench/contributions"; + +interface SourceState { + request?: RequestId; + message?: string; +} + +const useSourceState = create>(() => ({})); + +async function retrieve(path: string, choose: boolean) { + if (useSourceState.getState()[path]?.request !== undefined) return; + const editor = maybeEditorService(); + const project = useProject.getState().project; + if (!editor || !project) return; + const cursor = editor.activeCursor(); + const pos = cursor?.path === path ? { line: cursor.line, col: cursor.col } : { line: 0, col: 0 }; + const request = nextRequestId(); + useSourceState.setState({ [path]: { request } }); + try { + let sources: string | null = null; + if (choose) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: tx("Choose Sources…"), + filters: [{ name: tx("Source archives"), extensions: ["jar", "zip"] }], + }); + if (typeof selected !== "string") return; + sources = selected; + } + if (useProject.getState().project !== project) return; + const off = useProject.subscribe((state) => { + if (state.project !== project) void commands.cancelRequest(request); + }); + try { + const location = await call(commands.lspLibrarySources(request, path, pos, sources)); + if (useProject.getState().project !== project) return; + if (!location) { + useSourceState.setState({ [path]: { message: "Sources not found" } }); + return; + } + invalidateVirtualDocument(path); + if (!editor.openPaths?.().includes(path)) return; + // Do not take focus back if the user navigated elsewhere during the download. + if (editor.activePath() !== path) return; + await editor.openFile(location.path, { + selection: { line: location.range.start.line, col: location.range.start.col }, + }); + if (editor.activePath() === location.path) await editor.closePath?.(path); + } finally { + off(); + } + } catch (error) { + if (!isCancelled(error) && useProject.getState().project === project) { + useSourceState.setState({ [path]: { message: "Cannot load library sources" } }); + notifications().notify({ + level: "error", + title: "Cannot load library sources", + body: String(error), + group: "lsp", + }); + } + } finally { + if (useSourceState.getState()[path]?.request === request) useSourceState.setState({ [path]: {} }); + } +} + +export function registerLibrarySourcesActions(): () => void { + const path = (arg: unknown) => (typeof arg === "string" ? arg : maybeEditorService()?.activePath()); + const clear = () => { + for (const state of Object.values(useSourceState.getState())) { + if (state.request !== undefined) void commands.cancelRequest(state.request); + } + useSourceState.setState({}, true); + }; + const offProject = useProject.subscribe((state, previous) => { + if (state.project !== previous.project) clear(); + }); + const offActions = registerActions([ + { + id: "editor.downloadSources", + title: "Download Sources", + category: "Code", + hidden: true, + run: ({ arg }) => { + const p = path(arg); + return p ? retrieve(p, false) : undefined; + }, + }, + { + id: "editor.chooseSources", + title: "Choose Sources…", + category: "Code", + hidden: true, + run: ({ arg }) => { + const p = path(arg); + return p ? retrieve(p, true) : undefined; + }, + }, + { + id: "editor.cancelSourceDownload", + title: "Cancel", + category: "Code", + hidden: true, + run: async ({ arg }) => { + const p = path(arg); + const request = p ? useSourceState.getState()[p]?.request : undefined; + if (request !== undefined) await commands.cancelRequest(request); + }, + }, + ]); + return () => { + offActions(); + offProject(); + clear(); + }; +} + +export function useLibrarySources(path: string, decompiled: boolean) { + const state = useSourceState((s) => s[path]); + const project = useProject((s) => s.project); + const [support, setSupport] = useState<{ path: string; project: typeof project; download: boolean } | null>(null); + const choose = decompiled && !!project?.trusted && (path.startsWith("jdt:") || path.startsWith("jar:")); + useEffect(() => { + let current = true; + if (choose) + void call(commands.lspCanDownloadSources(path)).then( + (supported) => { + if (current) setSupport({ path, project, download: supported }); + }, + () => {}, + ); + return () => { + current = false; + }; + }, [path, choose, project]); + const download = choose && support?.path === path && support.project === project && support.download; + return { download, choose, busy: state?.request !== undefined, message: state?.message }; +} diff --git a/src/editor/core/view/Banner.tsx b/src/editor/core/view/Banner.tsx index 5cdbffde5..81b6d7fc6 100644 --- a/src/editor/core/view/Banner.tsx +++ b/src/editor/core/view/Banner.tsx @@ -5,6 +5,8 @@ import { useDocMeta } from "../model/document"; import styles from "./EditorArea.module.css"; import { classFileBreakpointReason } from "@/platform/classFiles"; import { tx } from "@/platform/i18n"; +import { executeAction } from "@/platform/actions"; +import { useLibrarySources } from "../librarySources"; const TEXT = { binary: "This file appears to be binary and is shown read-only.", @@ -39,6 +41,7 @@ export function VirtualBanner({ path: string; }) { const reason = classFileBreakpointReason(path); + const sources = useLibrarySources(path, decompiled); return (
@@ -50,6 +53,40 @@ export function VirtualBanner({ : tx("Library source (read-only).")} {reason && ` ${tx(reason)}`} + {sources.message && {tx(sources.message)}} + {sources.busy ? ( + <> + {tx("Loading library sources…")} + + + ) : ( + <> + {sources.download && ( + + )} + {sources.choose && ( + + )} + + )} {description && ( {description} diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css index d67c9a966..009e53965 100644 --- a/src/editor/core/view/EditorArea.module.css +++ b/src/editor/core/view/EditorArea.module.css @@ -100,18 +100,22 @@ } /* Library class notice (IDEA's yellowish decompiled-file bar). */ .libraryBanner { + flex-wrap: wrap; background: var(--bg-feedback-warning); border-bottom-color: var(--border-feedback-warning); } .virtualBannerText { + flex: none; min-width: 0; padding: 8px 0; + white-space: nowrap; } .bannerText { flex: none; white-space: nowrap; } .bannerDetail { + flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 82a19c9cc..32af3ddad 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -779,5 +779,12 @@ "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.", "Remote of {name}": "Remote of {name}", "Target branch of {name}": "Target branch of {name}", - "Remote project files cannot be shown in {fileManager}": "Remote project files cannot be shown in {fileManager}" + "Remote project files cannot be shown in {fileManager}": "Remote project files cannot be shown in {fileManager}", + "Download Sources": "Download Sources", + "Choose Sources…": "Choose Sources…", + "Source archives": "Source archives", + "Loading library sources…": "Loading library sources…", + "Sources not found": "Sources not found", + "Cannot load library sources": "Cannot load library sources", + "Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 81073bc33..cf2534fd7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7114,5 +7114,11 @@ "Gradle could not be started. Configure a local installation in Gradle settings, install Gradle on PATH, or use a project with a Gradle wrapper.": "无法启动 Gradle。请在 Gradle 设置中配置本地安装,将 Gradle 安装到 PATH,或使用带有 Gradle wrapper 的项目。", "Remote of {name}": "{name} 的远程", "Target branch of {name}": "{name} 的目标分支", - "Remote project files cannot be shown in {fileManager}": "无法在{fileManager}中显示远程项目的文件" + "Remote project files cannot be shown in {fileManager}": "无法在{fileManager}中显示远程项目的文件", + "Download Sources": "下载源代码", + "Choose Sources…": "选择源代码…", + "Source archives": "源代码归档", + "Loading library sources…": "正在加载库源代码…", + "Sources not found": "未找到源代码", + "Cannot load library sources": "无法加载库源代码" } diff --git a/src/platform/virtualDocs.ts b/src/platform/virtualDocs.ts index ec99f1059..dfb4c7463 100644 --- a/src/platform/virtualDocs.ts +++ b/src/platform/virtualDocs.ts @@ -69,6 +69,10 @@ export function virtualContainer(uri: string): string { const cache = new Map>(); +export function invalidateVirtualDocument(uri: string): void { + cache.delete(uri); +} + /** Contents of a virtual document (cached; failures are retried on the next call). */ export function virtualDocument(uri: string): Promise { let p = cache.get(uri);