Compare commits

..
Author SHA1 Message Date
l0ng-ai 6402bd0bc2 fix(java): show a re-installed SNAPSHOT's classes after reloading the project
A SNAPSHOT installed again replaces its jar in place, so the path, the URIs
of its classes and jdtls' class handles stay the same. The compile model
picked up the new jar on reload, but an open decompiled class kept the old
contents, and reopening it did too: the backend's virtual document cache,
the editor's cache and jdtls' decompiler cache (keyed by class handle) all
still held what was read from the old jar.

The backend now records the archive's modification time with each cached
library class and reads the class again once it changed. After a build tool
sync it asks jdtls to refresh external archives, close the changed archives'
package fragment roots and drop its decompiled sources
(openidea.java.refreshArchives), then replaces the open classes whose jar
changed. The editor drops its cached virtual documents when project roots
change. Covers jar: and jdt: classes, decompiled or read from the archive.
2026-11-13 20:00:37 +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
l0ng-ai 70b953c77a feat(deployment): server groups for uploading to several servers at once (#3149)
Deployment settings can hold server groups (+ › Server Group): a name and a set of the configured servers. Upload to… lists a group whenever one of its servers covers the selection and uploads to each of its servers in turn. Each server reports its result or error in its own notification, and a failure on one server doesn't stop the rest. Removing a server drops it from its groups. Upload to Default Server, download, sync and delete keep working on single servers.
2026-11-13 19:18:33 +08:00
19 changed files with 322 additions and 28 deletions

No files matched your search

+41 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
Binary file not shown.
+3
View File
@@ -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"/>
@@ -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
View File
@@ -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();
};
}
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
expect(await screen.findByText("Collecting data…")).toBeTruthy();
adapterData.library = "com.example.library.Library@1";
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
const value = await screen.findByText("com.example.library.Library@1");
const value = await screen.findByText("{Library@1}");
expect(screen.queryByText("Collecting data…")).toBeNull();
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
});
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
await screen.findByText("Collecting data…");
adapterData.library = "Library@2";
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
expect(await screen.findByText("Library@2")).toBeTruthy();
expect(await screen.findByText("{Library@2}")).toBeTruthy();
});
it("does nothing for an invalidation while the program runs", async () => {
+23 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { selectionExpression } from "../editor/valueTooltip";
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
import { inlineValuesFor } from "../editor/execution";
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
import type { StackFrame } from "@/platform/ipc";
@@ -150,3 +150,25 @@ describe("selectionExpression", () => {
expect(selectionExpression(multi)).toBeNull();
});
});
describe("shown values", () => {
it.each([
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
["Db@21", "Db", { label: "{Db@21}", text: "" }],
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
['"hello"', "String", { label: null, text: '"hello"' }],
["42", "int", { label: "{int}", text: "42" }],
// Other debuggers' values keep their text and type.
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
["0x1000", null, { label: null, text: "0x1000" }],
])("%s {%s}", (value, type, shown) => {
expect(shownValue(value, type)).toEqual(shown);
});
});
+5 -3
View File
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
import { activeSession, useExecutionPoint } from "../state";
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
import { tx } from "@/platform/i18n";
import { identifiersIn, inlineValue } from "../values";
import { identifiersIn, inlineValue, shownValue } from "../values";
import { valueHint } from "./valueTooltip";
import styles from "./editor.module.css";
@@ -113,11 +113,13 @@ export function inlineValuesFor(
evaluateName: id,
})
: raw;
const shown = shownValue(variable.value, variable.typeName);
const value = shown.text || shown.label || variable.value;
parts.push({
name: id,
text: `${id}: ${inlineValue(variable.value, 40)}`,
text: `${id}: ${inlineValue(value, 40)}`,
variable,
truncated: variable.value.length > 40,
truncated: value.length > 40,
});
if (parts.length >= MAX_PER_LINE) break;
}
+14 -8
View File
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
import { workflowSettings } from "@/platform/workflowSettings";
import { navigationModifierHeld } from "@/platform/navigationModifier";
import { activeSession, evaluate } from "../state";
import { inlineValue } from "../values";
import { inlineValue, shownValue } from "../values";
import { tx } from "@/platform/i18n";
import { iconMarkup, selectableText } from "@/ui";
import { mountValueTree } from "../ui/ValueHintTree";
@@ -70,13 +70,19 @@ export function valueHint(
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expression} = `;
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
if (value.typeName) {
const type = document.createElement("span");
type.className = styles.valueType!;
type.textContent = ` {${value.typeName}}`;
dom.append(type);
}
const shown = shownValue(value.value, value.typeName);
// A JVM object's `{Type@id}` leads its value; other types follow it.
const leading = shown.label?.includes("@") ?? false;
const type = (text: string) => {
const span = document.createElement("span");
span.className = styles.valueType!;
span.textContent = text;
return span;
};
dom.append(name);
if (leading) dom.append(type(`${shown.label} `));
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
return { dom, destroy: () => tree?.unmount() };
}
+12 -7
View File
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
import { methodAt } from "../breakpoints/symbols";
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
import { blurDebugView, focusDebugView } from "./focusedView";
import { classifyValue, oneLine, sortVariables } from "../values";
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
import { valueText } from "../valueText";
import { workflowSettings } from "@/platform/workflowSettings";
import styles from "./VariablesView.module.css";
@@ -486,20 +486,25 @@ function VarRow({
</span>
);
const kind = node.error ? null : classifyValue(node.value, node.typeName);
const shown = shownValue(node.value, node.typeName);
return (
<span className={styles.row}>
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
{!shown.entry && (
<>
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
</>
)}
{node.error ? (
<span className={styles.error}>{oneLine(node.error)}</span>
) : (
<>
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
{shown.label && <span className={styles.type}>{shown.label}</span>}
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
{oneLine(node.value)}
{oneLine(shown.text)}
</span>
{overflow && (
<button
+32
View File
@@ -69,3 +69,35 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
}
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
export interface ShownValue {
label: string | null;
text: string;
/** A map entry: the row shows `key -> value` alone, without its index. */
entry?: true;
}
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
/**
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
* a type. Values in other forms keep the adapter's text under `{type}`.
*/
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
if (!typeName) return plain;
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
const object = JVM_OBJECT.exec(value);
if (!object) return plain;
const [, head, id, rest = ""] = object;
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
if (simple(head!) !== simple(typeName)) return plain;
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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 });