feat(java): download sources from decompiled library views (#2421)

Fixes #2381

Opening a Maven library class without a sources jar showed decompiled code with no way to get the real source. The decompiled view's banner now offers Download Sources, which resolves the artifact's -sources.jar into the local repository through the project's Maven launcher and repositories (maven-dependency-plugin get, non-transitive), with progress, cancellation and errors in the banner, then reopens the class at the same declaration; Choose Sources… attaches a jar through the existing library source archives setting. Declaration matching after reopening now skips calls and comments. Gradle downloads are not covered yet.
This commit is contained in:
l0ng-ai
2026-11-10 05:15:04 +08:00
committed by GitHub
parent 6b70e1ea08
commit ccc6bd9751
16 changed files with 705 additions and 4 deletions
+2
View File
@@ -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"];
+124
View File
@@ -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<Artifact> {
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::<Option<_>>()?;
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<Option<PathBuf>> {
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::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().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}");
}
}
}
+84
View File
@@ -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<bool> {
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<std::path::PathBuf>,
) -> IdeResult<Option<Location>> {
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<String> {
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(
+58
View File
@@ -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<PathBuf> {
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<Option<Location>> {
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
+59 -2
View File
@@ -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`: `<name>-sources.jar` next to it (Maven
/// repository), or in a sibling hash folder (Gradle `files-2.1/<g>/<a>/<v>/<hash>/`).
fn sources_jars(jar: &Path) -> Vec<PathBuf> {
@@ -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]
+19
View File
@@ -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<bool> {
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<std::path::PathBuf>,
) -> IdeResult<Option<Location>> {
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(
+2
View File
@@ -240,6 +240,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+11
View File
@@ -456,6 +456,17 @@ export const commands = {
* classes and disk class files. Cached per project.
*/
lspVirtualDocument: (uri: string) => typedError<VirtualDocument, IdeError>(__TAURI_INVOKE("lsp_virtual_document", { uri })),
lspCanDownloadSources: (uri: string) => typedError<boolean, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("lsp_resolve_class_file", { path })),
/**
* References of the symbol at `pos`. `project_wide` also searches project files
+2
View File
@@ -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 }),
+139
View File
@@ -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<typeof import("@/platform/ipc")>()),
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<typeof import("@/platform/virtualDocs")>()),
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(<VirtualBanner path={uri} decompiled={decompiled} description="lib-1.jar" />);
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();
});
+145
View File
@@ -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<Record<string, SourceState>>(() => ({}));
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 };
}
+37
View File
@@ -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 (
<div className={`${styles.banner} ${styles.libraryBanner}`} role="status">
<Icon name={decompiled ? "info" : "lock"} size={14} className={styles.bannerIcon} />
@@ -50,6 +53,40 @@ export function VirtualBanner({
: tx("Library source (read-only).")}
{reason && ` ${tx(reason)}`}
</span>
{sources.message && <span>{tx(sources.message)}</span>}
{sources.busy ? (
<>
<span>{tx("Loading library sources…")}</span>
<button
type="button"
className={styles.bannerLink}
onClick={() => void executeAction("editor.cancelSourceDownload", path)}
>
<T>Cancel</T>
</button>
</>
) : (
<>
{sources.download && (
<button
type="button"
className={styles.bannerLink}
onClick={() => void executeAction("editor.downloadSources", path)}
>
<T>Download Sources</T>
</button>
)}
{sources.choose && (
<button
type="button"
className={styles.bannerLink}
onClick={() => void executeAction("editor.chooseSources", path)}
>
<T>Choose Sources…</T>
</button>
)}
</>
)}
{description && (
<span className={styles.bannerDetail} title={description}>
{description}
@@ -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;
+8 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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": "无法加载库源代码"
}
+4
View File
@@ -69,6 +69,10 @@ export function virtualContainer(uri: string): string {
const cache = new Map<string, Promise<VirtualDocument>>();
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<VirtualDocument> {
let p = cache.get(uri);