mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-15 08:08:18 +00:00
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:
@@ -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"];
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "无法加载库源代码"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user