org.eclipse.dirigible
dirigible-components-ui-view-properties
diff --git a/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/endpoint/JavaLspQueryEndpoint.java b/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/endpoint/JavaLspQueryEndpoint.java
new file mode 100644
index 00000000000..b7f6a15f3fd
--- /dev/null
+++ b/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/endpoint/JavaLspQueryEndpoint.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.dirigible.components.ide.lsp.java.endpoint;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.annotation.security.RolesAllowed;
+import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint;
+import org.eclipse.dirigible.components.ide.lsp.java.process.JdtLsInstance;
+import org.eclipse.dirigible.components.ide.lsp.java.process.JdtLsManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.security.Principal;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * HTTP facade over a workspace's JDT Language Server for IDE views that live outside the Monaco
+ * editor iframe (Call Hierarchy, Type Hierarchy). Those views cannot reuse the editor's per-iframe
+ * LSP WebSocket, so each request here drives the shared {@link JdtLsInstance} directly.
+ *
+ *
+ * Request and response bodies are handled as raw JSON strings, parsed/produced with this module's
+ * own Jackson 2 {@link ObjectMapper}. Spring Boot 4's default HTTP message converter is Jackson 3
+ * ({@code tools.jackson}), which cannot bind to Jackson 2 {@code JsonNode} types — so the
+ * controller boundary deliberately stays on {@code String} and never exposes a {@code JsonNode}
+ * parameter or return value.
+ *
+ *
+ * URIs travel as the browser's virtual form ({@code file:///workspace//...}); the bridge
+ * translates to/from the real on-disk paths, so responses come back virtual and are usable by the
+ * views as-is.
+ */
+@RestController
+@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_IDE + "java-lsp")
+@RolesAllowed({"ADMINISTRATOR", "DEVELOPER"})
+@ConditionalOnProperty(name = "java.lsp.enabled", havingValue = "true", matchIfMissing = true)
+public class JavaLspQueryEndpoint {
+
+ private static final Logger logger = LoggerFactory.getLogger(JavaLspQueryEndpoint.class);
+
+ private static final long REQUEST_TIMEOUT_SECONDS = 60;
+
+ private final JdtLsManager manager;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ public JavaLspQueryEndpoint(JdtLsManager manager) {
+ this.manager = manager;
+ }
+
+ /** Resolves the call-hierarchy item at a position ({@code textDocument/prepareCallHierarchy}). */
+ @PostMapping("/call-hierarchy/prepare")
+ public ResponseEntity prepareCallHierarchy(@RequestBody String body, Principal principal) {
+ JsonNode b = parse(body);
+ return json(request(principal, workspaceOf(b), "textDocument/prepareCallHierarchy", positionParams(b)));
+ }
+
+ /** Callers of the given call-hierarchy item ({@code callHierarchy/incomingCalls}). */
+ @PostMapping("/call-hierarchy/incoming")
+ public ResponseEntity incomingCalls(@RequestBody String body, Principal principal) {
+ JsonNode b = parse(body);
+ return json(request(principal, workspaceOf(b), "callHierarchy/incomingCalls", itemParams(b)));
+ }
+
+ /** Callees of the given call-hierarchy item ({@code callHierarchy/outgoingCalls}). */
+ @PostMapping("/call-hierarchy/outgoing")
+ public ResponseEntity outgoingCalls(@RequestBody String body, Principal principal) {
+ JsonNode b = parse(body);
+ return json(request(principal, workspaceOf(b), "callHierarchy/outgoingCalls", itemParams(b)));
+ }
+
+ /** Resolves the type-hierarchy item at a position ({@code textDocument/prepareTypeHierarchy}). */
+ @PostMapping("/type-hierarchy/prepare")
+ public ResponseEntity prepareTypeHierarchy(@RequestBody String body, Principal principal) {
+ JsonNode b = parse(body);
+ return json(request(principal, workspaceOf(b), "textDocument/prepareTypeHierarchy", positionParams(b)));
+ }
+
+ /** Supertypes of the given type-hierarchy item ({@code typeHierarchy/supertypes}). */
+ @PostMapping("/type-hierarchy/supertypes")
+ public ResponseEntity supertypes(@RequestBody String body, Principal principal) {
+ JsonNode b = parse(body);
+ return json(request(principal, workspaceOf(b), "typeHierarchy/supertypes", itemParams(b)));
+ }
+
+ /** Subtypes of the given type-hierarchy item ({@code typeHierarchy/subtypes}). */
+ @PostMapping("/type-hierarchy/subtypes")
+ public ResponseEntity subtypes(@RequestBody String body, Principal principal) {
+ JsonNode b = parse(body);
+ return json(request(principal, workspaceOf(b), "typeHierarchy/subtypes", itemParams(b)));
+ }
+
+ // -------------------------------------------------------------------------
+
+ private JsonNode parse(String body) {
+ try {
+ return mapper.readTree(body);
+ } catch (Exception e) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid JSON body", e);
+ }
+ }
+
+ private static ResponseEntity json(JsonNode node) {
+ return ResponseEntity.ok()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(node.toString());
+ }
+
+ private JsonNode request(Principal principal, String workspace, String method, JsonNode params) {
+ if (workspace == null || workspace.isBlank()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing 'workspace'");
+ }
+ String username = principal != null ? principal.getName() : "anonymous";
+ try {
+ JdtLsInstance instance = manager.getOrStart(username, workspace);
+ instance.ensureInitialized()
+ .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ JsonNode response = instance.sendRequest(method, mapper.writeValueAsString(params))
+ .get(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ return response.path("result");
+ } catch (IllegalStateException notReady) {
+ // JDT.LS still starting up or not bundled in this build.
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, notReady.getMessage(), notReady);
+ } catch (InterruptedException ie) {
+ Thread.currentThread()
+ .interrupt();
+ throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Interrupted", ie);
+ } catch (Exception e) {
+ logger.warn("[java-lsp] {} failed for workspace {}", method, workspace, e);
+ throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "JDT.LS request failed", e);
+ }
+ }
+
+ private static String workspaceOf(JsonNode body) {
+ return body.path("workspace")
+ .asText(null);
+ }
+
+ /** Builds {@code {textDocument:{uri}, position:{line,character}}} from the request body. */
+ private ObjectNode positionParams(JsonNode body) {
+ ObjectNode params = mapper.createObjectNode();
+ params.putObject("textDocument")
+ .put("uri", body.path("uri")
+ .asText());
+ params.putObject("position")
+ .put("line", body.path("line")
+ .asInt())
+ .put("character", body.path("character")
+ .asInt());
+ return params;
+ }
+
+ /** Builds {@code {item: }} from the request body's {@code item} field. */
+ private ObjectNode itemParams(JsonNode body) {
+ ObjectNode params = mapper.createObjectNode();
+ params.set("item", body.path("item"));
+ return params;
+ }
+}
diff --git a/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsInstance.java b/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsInstance.java
index f2625bed2e1..9c2c67e9cc3 100644
--- a/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsInstance.java
+++ b/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsInstance.java
@@ -161,10 +161,14 @@ public synchronized CompletableFuture ensureInitialized() {
// root matches what sendToProcess uses for virtual→real URI translation.
String escapedUri = realRoot.replace("\\", "\\\\")
.replace("\"", "\\\"");
+ // Advertise call/type hierarchy so JDT.LS serves them to the REST facade (JavaLspQueryEndpoint)
+ // even when no browser editor is open. The editor client advertises the same set, so the
+ // capabilities survive an editor-driven re-initialize of the shared process.
+ String capabilities = "{\"workspace\":{\"executeCommand\":{\"dynamicRegistration\":false}}," + "\"textDocument\":{"
+ + "\"callHierarchy\":{\"dynamicRegistration\":false}," + "\"typeHierarchy\":{\"dynamicRegistration\":false}}}";
String initParams = "{\"processId\":" + pid + "," + "\"clientInfo\":{\"name\":\"dirigible-java-debug\"}," + "\"rootUri\":\""
+ escapedUri + "\"," + "\"workspaceFolders\":[{\"uri\":\"" + escapedUri + "\",\"name\":\"workspace\"}],"
- + "\"capabilities\":{\"workspace\":{\"executeCommand\":{\"dynamicRegistration\":false}}},"
- + "\"initializationOptions\":{}}";
+ + "\"capabilities\":" + capabilities + "," + "\"initializationOptions\":{}}";
CompletableFuture initResponse = sendRequest("initialize", initParams);
CompletableFuture result = new CompletableFuture<>();
diff --git a/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsManager.java b/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsManager.java
index 43a1a20964f..486a842ea0e 100644
--- a/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsManager.java
+++ b/components/ide/ide-java-lsp/src/main/java/org/eclipse/dirigible/components/ide/lsp/java/process/JdtLsManager.java
@@ -33,7 +33,9 @@
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
+import java.security.MessageDigest;
import java.util.ArrayList;
+import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -112,6 +114,15 @@ public void run(ApplicationArguments args) {
ensureInstalled();
available = true;
logger.info("[java-lsp] JDT.LS is ready at {}", jdtlsHome);
+ // Materialise the platform classpath now (it is cached on disk), so the first Java
+ // file the user opens does not pay the multi-second extraction on its critical path.
+ try {
+ int entries = classPathIndex.classPathEntries()
+ .size();
+ logger.info("[java-lsp] Pre-warmed compile classpath: {} entries", entries);
+ } catch (Exception warmEx) {
+ logger.warn("[java-lsp] Classpath pre-warm failed (will materialise lazily on first use)", warmEx);
+ }
} catch (Exception e) {
logger.warn("[java-lsp] JDT.LS is not available: {}. Java language support will be disabled.", e.getMessage());
}
@@ -197,11 +208,25 @@ private JdtLsInstance startInstance(String username, String workspace) throws Ex
Path dataDir = jdtlsHome.resolve("data")
.resolve(username)
.resolve(workspace);
- // Wipe stale workspace index so JDT.LS re-imports projects with the current .classpath.
- // Without this, a cached Eclipse workspace that references deleted JAR paths (e.g. from
- // a previous temp-dir extraction) causes unresolved-import errors on every restart.
- deleteDirectory(dataDir);
- Files.createDirectories(dataDir);
+ // Reuse the JDT.LS index across restarts so the (expensive) re-index of the platform classpath
+ // is not paid every time. The cached index only goes stale when the classpath itself changes
+ // (ClassPathIndex extracts to a stable dir and re-extracts only on a new build), so wipe the
+ // index only when a classpath fingerprint marker is missing or no longer matches.
+ Path fingerprintMarker = dataDir.resolveSibling(dataDir.getFileName() + ".classpath-fp");
+ String fingerprint = classpathFingerprint();
+ boolean indexValid = !fingerprint.isEmpty() && Files.isDirectory(dataDir) && Files.exists(fingerprintMarker)
+ && fingerprint.equals(readFingerprint(fingerprintMarker));
+ if (indexValid) {
+ logger.info("[java-lsp] Reusing cached JDT.LS index for {}/{}", sanitize(username), sanitize(workspace));
+ } else {
+ logger.info("[java-lsp] Building JDT.LS index for {}/{} (first run or classpath changed)", sanitize(username),
+ sanitize(workspace));
+ deleteDirectory(dataDir);
+ Files.createDirectories(dataDir);
+ if (!fingerprint.isEmpty()) {
+ Files.writeString(fingerprintMarker, fingerprint);
+ }
+ }
String launcherJar = findLauncherJar();
String configDir = resolveConfigDir();
@@ -331,7 +356,7 @@ private List buildCommand(String launcherJar, String configDir, String d
cmd.add("-Dlog.protocol=true");
cmd.add("-Dlog.level=ALL");
cmd.add("-noverify");
- cmd.add("-Xmx512m");
+ cmd.add("-Xmx" + DirigibleConfig.JAVA_LSP_MAX_HEAP.getStringValue());
cmd.add("-jar");
cmd.add(launcherJar);
cmd.add("-configuration");
@@ -357,6 +382,34 @@ private static Path workspaceRoot(String username, String workspace) {
.normalize();
}
+ /**
+ * SHA-256 over the sorted classpath entry paths; identifies whether a cached index is still valid.
+ */
+ private String classpathFingerprint() {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ classPathIndex.classPathEntries()
+ .stream()
+ .map(Path::toString)
+ .sorted()
+ .forEach(entry -> digest.update(entry.getBytes(StandardCharsets.UTF_8)));
+ return HexFormat.of()
+ .formatHex(digest.digest());
+ } catch (Exception e) {
+ logger.warn("[java-lsp] Could not compute classpath fingerprint; index will be rebuilt", e);
+ return "";
+ }
+ }
+
+ private static String readFingerprint(Path marker) {
+ try {
+ return Files.readString(marker)
+ .trim();
+ } catch (IOException e) {
+ return "";
+ }
+ }
+
private static void deleteDirectory(Path dir) throws IOException {
if (!Files.exists(dir)) {
return;
diff --git a/components/pom.xml b/components/pom.xml
index cc7bb211789..c4385c5c853 100644
--- a/components/pom.xml
+++ b/components/pom.xml
@@ -161,6 +161,7 @@
ui/view-console
ui/view-preview
ui/view-problems
+ ui/view-java-hierarchy
ui/view-properties
ui/view-git
ui/view-terminal
@@ -941,6 +942,11 @@
dirigible-components-ui-view-problems
${project.version}
+