Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 5e83d103cc fix(project-tree): list a re-installed SNAPSHOT's entries after reloading the project
A SNAPSHOT installed again replaces its jar in place, which no file watcher
reports, so folders already expanded inside it under External Libraries kept
the old entries, even after collapsing and expanding them again. The project
view now re-reads its loaded folders inside archives when project roots change
(a build tool sync); the backend's archive listing already follows the jar's
modification time and size.
2026-11-13 20:03:53 +08:00
l0ng-ai 6f83f8cad0 fix(java): show a re-installed SNAPSHOT's classes after reloading the project (#3157)
A SNAPSHOT installed again replaces its jar in place, so class URIs and jdtls' class handles stay the same; the backend's virtual document cache, the editor's cache and jdtls' decompiler cache all kept the old contents. Cached library classes now record their archive's modification time and are read again once it changes; after a build tool sync jdtls refreshes external archives, closes the changed roots and drops its decompiled sources (openidea.java.refreshArchives, java-usages 1.14.0), and open classes whose jar changed are replaced. The editor drops cached virtual documents when project roots change.
2026-11-13 20:01:02 +08:00
l0ng-ai b971c8b391 fix(ui): keep a right-click in an open menu from opening the toolbar menu (#3152)
Right-clicking an entry of the in-app main menu (Windows and Linux), such as a project under File › Open Recent, opened the title bar's toolbar menu (Project Widget, VCS Widget, …): the menu is rendered in a popup but is a child of the title bar in the component tree, so the contextmenu event reached the title bar's handler. A menu now consumes right-clicks itself, which covers every menu shown above another component's context menu.
2026-11-13 19:33:30 +08:00
l0ng-ai c333990404 fix(debugger): show JVM values as {Type@id} summary and map entries as key -> value (#3150)
The Variables view showed the Java debugger's text under its type: args = {String[]} String[0]@9, map = {HashMap} HashMap@10 size=1, and a map entry as 0 = {HashMap$Node} HashMap$Node@31 "a":"b". A JVM object now reads {String[0]@9} [] and {HashMap@10} size = 1, a map entry as "a" -> "b" without its index, and a String without its type. Inline values and the value tooltip use the same text (args: [], map: size = 1). Values from other debuggers keep their text and type.
2026-11-13 19:24:18 +08:00
16 змінених файлів з 269 додано та 7 видалено

No files matched your search

+41 -1
Переглянути файл
@@ -15,6 +15,7 @@ use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{DocId, FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::path::Path;
use std::sync::Arc;
fn join_err(e: tokio::task::JoinError) -> IdeError {
IdeError::internal(e.to_string())
@@ -330,21 +331,61 @@ pub async fn build_sync(
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let lsp = project.lsp.clone();
let documents = project.documents.clone();
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
let first = updates.first().cloned();
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
}
let changed = {
let lsp = lsp.clone();
tokio::task::spawn_blocking(move || lsp.changed_library_archives()).await.unwrap_or_default()
};
if let Some(file) = first
&& let Err(e) = lsp.java_refresh_archives(&file, &changed).await
{
tracing::debug!("openidea.java.refreshArchives: {e}");
}
refresh_library_classes(&lsp, &documents).await;
});
Ok(model)
}
/// Open library classes whose jar was replaced in place (a SNAPSHOT installed
/// again keeps its path) show the new contents, like a reopened one.
async fn refresh_library_classes(lsp: &Arc<ide_lsp::LspManager>, documents: &Arc<ide_workspace::Documents>) {
let changed = {
let (lsp, documents) = (lsp.clone(), documents.clone());
tokio::task::spawn_blocking(move || {
documents
.open_documents()
.into_iter()
.filter_map(|doc| Some((doc, documents.virtual_uri(doc).ok().flatten()?)))
.filter(|(_, uri)| lsp.library_class_changed(uri))
.collect::<Vec<_>>()
})
.await
.unwrap_or_default()
};
for (doc, uri) in changed {
match lsp.virtual_document(&uri).await {
Ok(vd) => {
if let Err(e) = documents.replace_virtual(doc, &vd.text, &vd.encoding, vd.has_bom) {
tracing::debug!("refreshing {uri}: {e}");
}
}
Err(e) => tracing::debug!("refreshing {uri}: {e}"),
}
}
}
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
@@ -1051,7 +1092,6 @@ mod maven_completion_tests {
use ide_build::maven::completion::PomCompletionKind;
use ide_core::CancellationToken;
use ide_workspace::dependencies::DependencyVersionSource;
use std::sync::Arc;
/// Cache answers the test plants before completing, so the op's Central
/// lookups hit the session cache and the network is never reached.
+15
Переглянути файл
@@ -809,6 +809,21 @@ impl LspManager {
.map_err(|e| crate::rpc_err(&server.name, "java/projectConfigurationsUpdate", e))
}
/// Asks jdtls to re-read library archives replaced in place (`openidea.java.refreshArchives`):
/// a SNAPSHOT installed again keeps its path, and JDT would keep showing the classes it read
/// from the old archive. Best-effort: skipped while jdtls is not already running.
pub async fn java_refresh_archives(&self, path: &Path, archives: &[PathBuf]) -> IdeResult<()> {
let java = LanguageId::new("java");
let Some(server) = self.servers_for(&java, path).into_iter().find(|s| s.name == SERVER) else {
return Ok(());
};
if server.running().is_none() {
return Ok(());
}
let archives = archives.iter().map(|a| Value::String(a.to_string_lossy().into_owned())).collect();
self.jdtls_command(path, "openidea.java.refreshArchives", archives).await.map(|_| ())
}
/// Pause autobuild before resolving the launch classpath. That query takes the
/// Java project's workspace scheduling rule, draining the interrupted autobuild.
/// Keep this guard until Maven outputs have been copied out of JDT's reach.
+43 -2
Переглянути файл
@@ -241,6 +241,9 @@ pub struct LspManager {
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
/// Modification time of the archive behind a cached library class, so a jar
/// replaced in place (a SNAPSHOT installed again) is read again.
virtual_stamps: Mutex<HashMap<String, std::time::SystemTime>>,
injections: injections::State,
/// Open notebooks by path. Locked before `docs` when both are.
notebooks: Mutex<HashMap<PathBuf, notebook::NotebookState>>,
@@ -508,6 +511,7 @@ impl LspManager {
go_mod_checked: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
virtual_stamps: Mutex::new(HashMap::new()),
injections: injections::State::default(),
notebooks: Mutex::new(HashMap::new()),
})
@@ -3202,14 +3206,44 @@ impl LspManager {
Ok(found)
}
/// Whether the archive behind a cached library class changed since it was read.
/// Stats the archive: call from a blocking worker.
pub fn library_class_changed(&self, uri: &str) -> bool {
let Some(stamp) = self.virtual_stamps.lock().get(uri).copied() else { return false };
virtual_doc::archive_modified(uri) != Some(stamp)
}
/// Archives behind cached library classes that changed since they were read.
/// Stats each archive: call from a blocking worker.
pub fn changed_library_archives(&self) -> Vec<PathBuf> {
let uris: Vec<String> = self.virtual_stamps.lock().keys().cloned().collect();
let mut archives: Vec<PathBuf> = uris
.iter()
.filter(|uri| self.library_class_changed(uri))
.filter_map(|uri| virtual_doc::archive(uri))
.collect();
archives.sort();
archives.dedup();
archives
}
/// 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
/// is read from its archive, a `jar:` class shown as its library source
/// (see [`library_source`]) or else decompiled by the server. Cached.
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
if let Some(d) = self.virtual_docs.lock().get(uri) {
return Ok(d.clone());
let cached = self.virtual_docs.lock().get(uri).cloned();
if let Some(d) = cached {
let stamp = self.virtual_stamps.lock().get(uri).copied();
let current = {
let uri = uri.to_owned();
tokio::task::spawn_blocking(move || virtual_doc::archive_modified(&uri)).await.ok().flatten()
};
if stamp.is_none() || stamp == current {
return Ok(d);
}
self.virtual_docs.lock().remove(uri);
}
let scheme = virtual_doc::scheme(uri).to_owned();
let jar_class = scheme == "jar" && library_source::parse(uri).is_some_and(|e| e.entry.ends_with(".class"));
@@ -3353,11 +3387,18 @@ impl LspManager {
if let Some(server) = server {
self.virtual_origins.lock().entry(uri.to_owned()).or_insert(server);
}
let stamp = virtual_doc::archive_modified(uri);
let mut cache = self.virtual_docs.lock();
let mut stamps = self.virtual_stamps.lock();
if cache.len() >= VIRTUAL_CACHE_LIMIT {
cache.clear();
stamps.clear();
}
cache.insert(uri.to_owned(), doc.clone());
match stamp {
Some(stamp) => stamps.insert(uri.to_owned(), stamp),
None => stamps.remove(uri),
};
doc
}
+37
Переглянути файл
@@ -172,6 +172,20 @@ pub fn description(uri: &str) -> Option<String> {
}
}
/// The archive a `jar:` or `jdt:` library class comes from.
pub(crate) fn archive(uri: &str) -> Option<std::path::PathBuf> {
match scheme(uri) {
"jar" => Some(crate::library_source::parse(uri)?.jar),
"jdt" => description(uri).filter(|p| p.ends_with(".jar") || p.ends_with(".jmod")).map(Into::into),
_ => None,
}
}
/// Modification time of the archive a `jar:` or `jdt:` library class comes from.
pub(crate) fn archive_modified(uri: &str) -> Option<std::time::SystemTime> {
std::fs::metadata(archive(uri)?).and_then(|m| m.modified()).ok()
}
/// Check both the package and actual archive, so duplicate type names never pick another jar.
pub(crate) fn matches_class(uri: &str, jar: &std::path::Path, package: &str, name: &str) -> bool {
let segs = segments(uri);
@@ -304,6 +318,29 @@ mod tests {
assert_eq!(description("foo://x/y"), None);
}
#[test]
fn finds_the_archive_behind_a_library_class() {
assert_eq!(archive(JDT), Some("/Users/me/.m2/repository/spring-boot-4.1.1.jar".into()));
assert_eq!(
archive("jdt://contents/java.base/java.lang/String.class?=p/%5C/jdk%5C/lib%5C/jrt-fs.jar%60java.base=x"),
None
);
assert_eq!(archive("jdt://contents/a.jar/p/C.class"), None);
assert_eq!(archive("file:///a/C.java"), None);
let dir = tempfile::tempdir().unwrap();
let jar = dir.path().join("lib-1.0-SNAPSHOT.jar");
std::fs::write(&jar, b"PK").unwrap();
let uri = format!("jar:file://{}!/p/C.class", jar.display());
assert_eq!(archive(&uri), Some(jar.clone()));
let before = archive_modified(&uri).unwrap();
let file = std::fs::File::options().write(true).open(&jar).unwrap();
file.set_modified(before + std::time::Duration::from_secs(5)).unwrap();
assert_ne!(archive_modified(&uri), Some(before));
std::fs::remove_file(&jar).unwrap();
assert_eq!(archive_modified(&uri), None);
}
#[test]
fn detects_generated_code() {
assert!(is_decompiled(
+1 -1
Переглянути файл
@@ -1547,7 +1547,7 @@ pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
install_bundle(
data_dir,
"java-usages",
"1.13.0",
"1.14.0",
include_bytes!("../../../java/java-usages/openidea-java-usages.jar"),
)
}
+1 -1
Переглянути файл
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OpenIDEA Java usages
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
Bundle-Version: 1.13.0
Bundle-Version: 1.14.0
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
Бінарний файл не відображається.
+3
Переглянути файл
@@ -17,6 +17,9 @@
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
<command id="openidea.java.implementations"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.archives.RefreshArchivesCommand">
<command id="openidea.java.refreshArchives"/>
</delegateCommandHandler>
<delegateCommandHandler class="ai.openidea.hierarchy.HierarchyCommand">
<command id="openidea.java.hierarchy.members"/>
<command id="openidea.java.hierarchy.refactor"/>
+68
Переглянути файл
@@ -0,0 +1,68 @@
package ai.openidea.archives;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.core.runtime.Path;
import org.eclipse.jdt.core.IJavaModel;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.ls.core.internal.IDelegateCommandHandler;
import org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin;
/**
* Re-reads library archives replaced in place (a SNAPSHOT installed again keeps its path). The
* arguments are the archives' file system paths. Their package fragment roots are closed, which
* drops the class files read from the old archive, and jdtls' decompiled sources (cached by class
* handle, which a replaced archive keeps) are dropped, so the classes are read again.
*/
public final class RefreshArchivesCommand implements IDelegateCommandHandler {
@Override
public Object executeCommand(String command, List<Object> arguments, IProgressMonitor monitor) throws Exception {
IJavaModel model = JavaCore.create(ResourcesPlugin.getWorkspace().getRoot());
model.refreshExternalArchives(null, monitor);
Set<IPath> archives = new HashSet<>();
for (Object argument : arguments) {
if (argument instanceof String path) archives.add(Path.fromOSString(path));
}
int closed = 0;
if (archives.isEmpty()) return closed;
clearDecompilerCache();
for (IJavaProject project : model.getJavaProjects()) {
for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
if (monitor.isCanceled()) throw new OperationCanceledException();
if (root.isArchive() && root.isExternal() && archives.contains(root.getPath()) && root.isOpen()) {
root.close();
closed++;
}
}
}
return closed;
}
/** jdtls keeps decompiled sources in a static per-decompiler LRU with no API to drop them. */
private static void clearDecompilerCache() {
try {
Class<?> impl = IDelegateCommandHandler.class.getClassLoader()
.loadClass("org.eclipse.jdt.ls.core.internal.decompiler.DecompilerImpl");
Field field = impl.getDeclaredField("decompilerCache");
field.setAccessible(true);
if (field.get(null) instanceof Map<?, ?> caches) {
synchronized (caches) {
for (Object cache : caches.values()) {
if (cache instanceof Map<?, ?> map) map.clear();
}
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
JavaLanguageServerPlugin.logException("Clearing decompiled sources", e);
}
}
}
+6 -2
Переглянути файл
@@ -1,10 +1,10 @@
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 { call, commands, isCancelled, nextRequestId, onIdeEvent, type RequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { invalidateVirtualDocument } from "@/platform/virtualDocs";
import { invalidateVirtualDocument, invalidateVirtualDocuments } from "@/platform/virtualDocs";
import { maybeEditorService, notifications } from "@/workbench/contributions";
interface SourceState {
@@ -82,6 +82,9 @@ export function registerLibrarySourcesActions(): () => void {
const offProject = useProject.subscribe((state, previous) => {
if (state.project !== previous.project) clear();
});
// A reload may have replaced a library jar in place (a SNAPSHOT installed again): reopened
// classes fetch their contents again (the backend refreshes the open ones).
const offRoots = onIdeEvent("projectRootsChanged", () => invalidateVirtualDocuments());
const offActions = registerActions([
{
id: "editor.downloadSources",
@@ -118,6 +121,7 @@ export function registerLibrarySourcesActions(): () => void {
return () => {
offActions();
offProject();
offRoots();
clear();
};
}
+2
Переглянути файл
@@ -34,6 +34,7 @@ import {
initProjectTree,
leadSelection,
reloadAll,
reloadArchiveDirs,
reloadLoadedDirs,
setChangedFiles,
setOpenFiles,
@@ -253,6 +254,7 @@ export function activate(): () => void {
initProjectTypes(),
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
onIdeEvent("excludedDirsChanged", (e) => void reloadLoadedDirs(e.dirs)),
onIdeEvent("projectRootsChanged", () => void reloadArchiveDirs()),
];
return () => {
disposers.reverse().forEach((d) => d());
+23
Переглянути файл
@@ -3,6 +3,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs";
import { visibleInfo } from "./model";
import {
applyFsChange,
reloadArchiveDirs,
reloadLoadedDirs,
currentModel,
expandAll,
@@ -114,6 +115,28 @@ describe("refresh of excluded folders", () => {
});
});
describe("refresh of library archives", () => {
it("re-reads the loaded folders inside archives only", async () => {
await fs.createFile("/m2/lib.jar!/p/A.class");
await loadDir(ROOT);
await loadDir("/m2/lib.jar!");
await loadDir("/m2/lib.jar!/p");
await fs.createFile("/m2/lib.jar!/p/B.class");
const spy = vi.spyOn(fs, "readDir");
await reloadArchiveDirs();
expect(spy.mock.calls.map(([p]) => p).sort()).toEqual(["/m2/lib.jar!", "/m2/lib.jar!/p"]);
expect(
useProjectTree
.getState()
.listings.get("/m2/lib.jar!/p")
?.map((e) => e.name),
).toEqual(["A.class", "B.class"]);
spy.mockRestore();
});
});
describe("refresh on ignore-rule changes", () => {
it("refreshes loaded descendants once, preserving selection and unrelated folders", async () => {
await fs.createFile("/p/docs/sub/note.md");
+10
Переглянути файл
@@ -565,6 +565,16 @@ export async function reloadLoadedDirs(dirs: readonly string[]) {
await Promise.all(dirs.filter((p) => listings.has(p)).map((p) => loadDir(p, true)));
}
/**
* Re-reads the loaded folders inside archives (`<archive>!…`): a build tool sync may have replaced
* a library jar in place (a SNAPSHOT installed again), which no file watcher reports.
*/
export async function reloadArchiveDirs() {
const { listings } = get();
const inArchive = (p: string) => p.endsWith("!") || p.includes("!/") || p.includes("!\\");
await Promise.all([...listings.keys()].filter(inArchive).map((p) => loadDir(p, true)));
}
/** Re-reads every loaded folder (View › Reload from Disk). */
export async function reloadAll() {
const { listings } = get();
+5
Переглянути файл
@@ -73,6 +73,11 @@ export function invalidateVirtualDocument(uri: string): void {
cache.delete(uri);
}
/** Forgets every cached document: a build tool reload may have replaced library jars in place. */
export function invalidateVirtualDocuments(): void {
cache.clear();
}
/** 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);
+3
Переглянути файл
@@ -344,6 +344,9 @@ export function MenuList({
aria-label={aria["aria-label"]}
className={styles.menu}
onKeyDown={onKeyDown}
// A right-click in an open menu does nothing; without this it reaches whatever is under the
// menu in the component tree, e.g. the title bar's toolbar menu behind File › Open Recent.
onContextMenu={(e) => e.preventDefault()}
onPointerMove={(e) => {
if (e.currentTarget.contains(e.target as Node)) pointerMove({ x: e.clientX, y: e.clientY });
}}
+11
Переглянути файл
@@ -283,6 +283,17 @@ describe("Menu action enablement", () => {
expect(open).toHaveFocus();
});
it("keeps a right-click in a menu from reaching the components behind it", () => {
const behind = vi.fn((e: { defaultPrevented: boolean }) => e.defaultPrevented);
render(
<div onContextMenu={(e) => behind(e)}>
<MenuList items={[{ label: "Recent", run: () => undefined }]} onDone={() => {}} />
</div>,
);
fireEvent.contextMenu(screen.getByRole("menuitem", { name: "Recent" }));
expect(behind).toHaveReturnedWith(true);
});
it("reports a hovered row once and lets the keyboard continue from it", () => {
const active: string[] = [];
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => undefined });