diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLanguageServerPlugin.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLanguageServerPlugin.java index 0635b7ca5b..a46570cf4e 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLanguageServerPlugin.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLanguageServerPlugin.java @@ -61,8 +61,6 @@ public class BootLanguageServerPlugin extends AbstractUIPlugin { public static final String BOOT_LS_DEFINITION_ID = "org.eclipse.languageserver.languages.springboot"; - private BootLsState lsState = new BootLsState(); - public BootLanguageServerPlugin() { // Empty } @@ -181,10 +179,6 @@ private ImageDescriptor createStereotypeImageDescriptor(URL url, IPath p) { return ImageDescriptor.createFromURL(url); } - public BootLsState getLsState() { - return lsState; - } - public Image getStereotypeImage(String name) { return getImageRegistry().get(STEREOTYPE_IMG_PREFIX + name); } diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLsState.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLsState.java deleted file mode 100644 index 16362fd0b3..0000000000 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/BootLsState.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.springframework.tooling.boot.ls; - -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -import org.eclipse.core.runtime.ListenerList; - -public class BootLsState { - - private enum State { - INITIALIZED, - INDEXED, - STOPPED - } - - private AtomicReference state = new AtomicReference<>(State.STOPPED); - private ListenerList> listeners = new ListenerList<>(); - - public boolean isIndexed() { - return state.get() == State.INDEXED; - } - - void indexed() { - state.set(State.INDEXED); - listeners.forEach(l -> l.accept(this)); - } - - void initialized() { - state.set(State.INITIALIZED); - listeners.forEach(l -> l.accept(this)); - } - - void stopped() { - state.set(State.STOPPED); - listeners.forEach(l -> l.accept(this)); - } - - public void addStateChangedListener(Consumer l) { - listeners.add(l); - } - - public void removeStateChangedListener(Consumer l) { - listeners.remove(l); - } - -} diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/DelegatingStreamConnectionProvider.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/DelegatingStreamConnectionProvider.java index 9dfc85fc90..71777e7958 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/DelegatingStreamConnectionProvider.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/DelegatingStreamConnectionProvider.java @@ -36,7 +36,6 @@ import org.eclipse.lsp4j.ExecuteCommandParams; import org.eclipse.lsp4j.InitializeResult; import org.eclipse.lsp4j.jsonrpc.messages.Message; -import org.eclipse.lsp4j.jsonrpc.messages.NotificationMessage; import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage; import org.eclipse.lsp4j.services.LanguageServer; import org.eclipse.ui.PlatformUI; @@ -128,7 +127,6 @@ public InputStream getErrorStream() { @Override public void stop() { - BootLanguageServerPlugin.getDefault().getLsState().stopped(); IProxyService proxyService = PlatformUI.getWorkbench().getService(IProxyService.class); if (proxyService != null) { proxyService.removeProxyChangeListener(proxySettingsListener); @@ -182,14 +180,6 @@ public void handleMessage(Message message, LanguageServer languageServer, URI ro )); } - BootLanguageServerPlugin.getDefault().getLsState().initialized(); - } - } else if (message instanceof NotificationMessage) { - NotificationMessage notification = (NotificationMessage) message; - // Handle spring/index/updated notification - if ("spring/index/updated".equals(notification.getMethod())) { - // Emit event to the Flux - BootLanguageServerPlugin.getDefault().getLsState().indexed(); } } diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/GroupingAction.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/GroupingAction.java index 500dc8711e..237b9afbd6 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/GroupingAction.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/GroupingAction.java @@ -30,7 +30,7 @@ public void run() { GroupingDialog dialog = new GroupingDialog(UI.getActiveShell(), structureView::fetchGroups, structureView::getGroupings); if (dialog.open() == IDialogConstants.OK_ID) { structureView.setGroupings(dialog.getResult()); - structureView.fetchStructure(false); + structureView.fetchStructure(null, false); } } diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/LogicalStructureView.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/LogicalStructureView.java index 37bf33cf15..606bbf05cb 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/LogicalStructureView.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/LogicalStructureView.java @@ -10,12 +10,15 @@ *******************************************************************************/ package org.springframework.tooling.boot.ls.views; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.viewers.TreeViewer; @@ -26,10 +29,10 @@ import org.eclipse.swt.widgets.Composite; import org.eclipse.ui.IActionBars; import org.eclipse.ui.part.ViewPart; -import org.springframework.tooling.boot.ls.BootLanguageServerPlugin; -import org.springframework.tooling.boot.ls.BootLsState; import org.springframework.tooling.boot.ls.views.StructureClient.Groups; import org.springframework.tooling.boot.ls.views.StructureClient.StructureParameter; +import org.springframework.tooling.ls.eclipse.commons.LanguageServerCommonsActivator; +import org.springframework.tooling.ls.eclipse.commons.SpringIndexState; /** @@ -47,27 +50,38 @@ public class LogicalStructureView extends ViewPart { final private GroupingRepository groupingRepository = new GroupingRepository(); - private final AtomicBoolean fetching = new AtomicBoolean(); + private Consumer> indexStateListener = projectNames -> fetchStructure(projectNames, false); - private Consumer lsStateListener = state -> { - if (state.isIndexed()) { - fetchStructure(false); - } else { - UI.getDisplay().asyncExec(() -> treeViewer.setInput(null)); - } - }; - - void fetchStructure(boolean updateMetadata) { - if (!fetching.compareAndExchange(false, true)) { - structureClient.fetchStructure(new StructureParameter(updateMetadata, getGroupings())).thenAccept(nodes -> { - fetching.set(false); - UI.getDisplay().asyncExec(() -> { - Object[] expanded = treeViewer.getExpandedElements(); - treeViewer.setInput(nodes); - treeViewer.setExpandedElements(expanded); + void fetchStructure(Set affectedProjects, boolean updateMetadata) { + structureClient.fetchStructure(new StructureParameter(updateMetadata, affectedProjects, getGroupings())) + .thenAccept(nodes -> { + UI.getDisplay().asyncExec(() -> { + Object[] expanded = treeViewer.getExpandedElements(); + if (affectedProjects == null || affectedProjects.isEmpty()) { + treeViewer.setInput(nodes); + } else { + @SuppressWarnings("unchecked") + List oldNodes = (List) treeViewer.getInput(); + if (oldNodes == null) { + oldNodes = Collections.emptyList(); + } + List newNodes = new ArrayList<>(oldNodes.size() + nodes.size()); + Map> nodesMap = affectedProjects.stream().collect(Collectors.toMap(e -> e, e -> nodes.stream().filter(n -> e.equals(n.getProjectId())).findFirst())); + for (StereotypeNode n : oldNodes) { + String projectName = n.getProjectId(); + if (nodesMap.containsKey(projectName)) { + nodesMap.remove(projectName).ifPresent(newNodes::add); + } else { + newNodes.add(n); + } + } + nodesMap.values().stream().filter(opt -> opt.isPresent()).map(opt -> opt.get()).forEach(newNodes::add); + treeViewer.setInput(newNodes); + } + + treeViewer.setExpandedElements(expanded); + }); }); - }); - } } CompletableFuture> fetchGroups() { @@ -88,15 +102,13 @@ public void createPartControl(Composite parent) { // Set initial input - placeholder data treeViewer.setInput(Collections.emptyList()); - BootLsState lsState = BootLanguageServerPlugin.getDefault().getLsState(); + fetchStructure(null, false); - if (lsState.isIndexed()) { - fetchStructure(false); - } + final SpringIndexState springIndexState = LanguageServerCommonsActivator.getInstance().getSpringIndexState(); - lsState.addStateChangedListener(lsStateListener); + springIndexState.addStateChangedListener(indexStateListener); - treeViewer.getControl().addDisposeListener(e -> lsState.removeStateChangedListener(lsStateListener)); + treeViewer.getControl().addDisposeListener(e -> springIndexState.removeStateChangedListener(indexStateListener)); treeViewer.addDoubleClickListener(e -> { Object o = ((IStructuredSelection) e.getSelection()).getFirstElement(); diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/RefreshAction.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/RefreshAction.java index b5fd6816bc..68705d991e 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/RefreshAction.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/RefreshAction.java @@ -27,6 +27,6 @@ class RefreshAction extends Action { @Override public void run() { - this.logicalStructureView.fetchStructure(true); + this.logicalStructureView.fetchStructure(null, true); } } \ No newline at end of file diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNode.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNode.java index fa833cd392..e6c969bbea 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNode.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNode.java @@ -20,7 +20,14 @@ * * @author Alex Boyko */ -record StereotypeNode(String id, String text, String icon, Location location, Location reference, Map attributes, StereotypeNode[] children) { +record StereotypeNode( + String id, + String text, + String icon, + Location location, + Location reference, + Map attributes, + StereotypeNode[] children) { @Override public boolean equals(Object obj) { @@ -39,6 +46,11 @@ public int hashCode() { public String toString() { return text; } - + + public String getProjectId() { + return attributes.containsKey(StereotypeNodeDeserializer.PROJECT_ID) + ? (String) attributes.get(StereotypeNodeDeserializer.PROJECT_ID) + : null; + } } diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNodeDeserializer.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNodeDeserializer.java index c4abb02c74..df54bc09a5 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNodeDeserializer.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StereotypeNodeDeserializer.java @@ -21,7 +21,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -@SuppressWarnings({ "restriction", "serial" }) +@SuppressWarnings({ "restriction" }) public class StereotypeNodeDeserializer implements com.google.gson.JsonDeserializer { private static final String LOCATION = "location"; @@ -30,6 +30,8 @@ public class StereotypeNodeDeserializer implements com.google.gson.JsonDeseriali private static final String CHILDREN = "children"; private static final String REFERENCE = "reference"; + static final String PROJECT_ID = "projectId"; + private static final String NODE_ID = "nodeId"; diff --git a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StructureClient.java b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StructureClient.java index 8989ceec45..8dc70a0b0a 100644 --- a/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StructureClient.java +++ b/eclipse-language-servers/org.springframework.tooling.boot.ls/src/org/springframework/tooling/boot/ls/views/StructureClient.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; @@ -22,12 +23,12 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; -@SuppressWarnings({ "restriction", "serial" }) +@SuppressWarnings({ "restriction" }) class StructureClient { record Groups (String projectName, List groups) {} record Group (String identifier, String displayName) {} - record StructureParameter(boolean updateMetadata, Map> groups) {} + record StructureParameter(boolean updateMetadata, Collection affectedProjects, Map> groups) {} private static final String FETCH_SPRING_BOOT_STRUCTURE = "sts/spring-boot/structure"; private static final String FETCH_STRUCTURE_GROUPS = "sts/spring-boot/structure/groups"; diff --git a/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/LanguageServerCommonsActivator.java b/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/LanguageServerCommonsActivator.java index 9406774cbf..5e46af50d8 100644 --- a/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/LanguageServerCommonsActivator.java +++ b/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/LanguageServerCommonsActivator.java @@ -64,6 +64,11 @@ public void propertyChange(PropertyChangeEvent event) { private AnnotationPreference bootHintAnnotationPreference; + /* + * Singleton state. We assume there is going to be one server at most serving info about SpringIndex + */ + private SpringIndexState springIndexState = new SpringIndexState(); + public LanguageServerCommonsActivator() { } @@ -131,4 +136,8 @@ public static void logInfo(String message) { instance.getLog().log(new Status(IStatus.INFO, instance.getBundle().getSymbolicName(), message)); } + public SpringIndexState getSpringIndexState() { + return springIndexState; + } + } diff --git a/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/STS4LanguageClientImpl.java b/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/STS4LanguageClientImpl.java index ea59016198..629eaf1d15 100644 --- a/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/STS4LanguageClientImpl.java +++ b/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/STS4LanguageClientImpl.java @@ -53,7 +53,7 @@ import org.eclipse.jface.text.source.ISourceViewer; import org.eclipse.jface.text.source.ISourceViewerExtension5; import org.eclipse.lsp4e.LSPEclipseUtils; -import org.eclipse.lsp4e.LanguageClientImpl; +import org.eclipse.lsp4e.client.DefaultLanguageClient; import org.eclipse.lsp4j.CodeLens; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.MarkupContent; @@ -102,7 +102,7 @@ import com.google.common.collect.ImmutableMap; @SuppressWarnings("restriction") -public class STS4LanguageClientImpl extends LanguageClientImpl implements STS4LanguageClient { +public class STS4LanguageClientImpl extends DefaultLanguageClient implements STS4LanguageClient { private final Executor executor; @@ -635,6 +635,7 @@ public void liveProcessMemoryMetricsDataUpdated(LiveProcessSummary arg0) { @Override public void indexUpdated(IndexUpdatedParams indexUpdateDetails) { + LanguageServerCommonsActivator.getInstance().getSpringIndexState().indexed(indexUpdateDetails.getAffectedProjects()); } @Override diff --git a/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/SpringIndexState.java b/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/SpringIndexState.java new file mode 100644 index 0000000000..8134e05ac8 --- /dev/null +++ b/eclipse-language-servers/org.springframework.tooling.ls.eclipse.commons/src/org/springframework/tooling/ls/eclipse/commons/SpringIndexState.java @@ -0,0 +1,37 @@ +/******************************************************************************* + * Copyright (c) 2025 Broadcom, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Broadcom, Inc. - initial API and implementation + *******************************************************************************/ +package org.springframework.tooling.ls.eclipse.commons; + +import java.util.Collections; +import java.util.Set; +import java.util.function.Consumer; + +import org.eclipse.core.runtime.ListenerList; + +public final class SpringIndexState { + + private ListenerList>> listeners = new ListenerList<>(); + + public void addStateChangedListener(Consumer> l) { + listeners.add(l); + } + + public void removeStateChangedListener(Consumer> l) { + listeners.remove(l); + } + + /* + * Ideally, this does not need to be synchronized, but synchronization is necessary if more than one server may notify about index changes to avoid potential errors. + */ + synchronized void indexed(Set projectNames) { + listeners.forEach(l -> l.accept(Collections.unmodifiableSet(projectNames))); + } +} diff --git a/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/IndexUpdatedParams.java b/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/IndexUpdatedParams.java index 6858dc0da3..f83ac2c56e 100644 --- a/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/IndexUpdatedParams.java +++ b/headless-services/commons/commons-lsp-extensions/src/main/java/org/springframework/ide/vscode/commons/protocol/spring/IndexUpdatedParams.java @@ -10,21 +10,21 @@ *******************************************************************************/ package org.springframework.ide.vscode.commons.protocol.spring; -import java.util.List; +import java.util.Set; public class IndexUpdatedParams { - private List affectedProjects; + private Set affectedProjects; - public List getAffectedProjects() { + public Set getAffectedProjects() { return affectedProjects; } - public void setAffectedProjects(List affectedProjects) { + public void setAffectedProjects(Set affectedProjects) { this.affectedProjects = affectedProjects; } - public static IndexUpdatedParams of(List affectedProjects) { + public static IndexUpdatedParams of(Set affectedProjects) { IndexUpdatedParams params = new IndexUpdatedParams(); params.setAffectedProjects(affectedProjects); return params; @@ -32,7 +32,7 @@ public static IndexUpdatedParams of(List affectedProjects) { public static IndexUpdatedParams of(String affectedProject) { IndexUpdatedParams params = new IndexUpdatedParams(); - params.setAffectedProjects(List.of(affectedProject)); + params.setAffectedProjects(Set.of(affectedProject)); return params; } diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java index a1a92f6580..57b102237d 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/app/SpringSymbolIndex.java @@ -24,6 +24,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -411,7 +412,7 @@ private CompletableFuture _initializeProject(IJavaProject project, boolean CompletableFuture future = CompletableFuture.allOf(futures); - future = future.thenAccept(v -> server.getClient().indexUpdated(IndexUpdatedParams.of(List.of(project.getElementName())))).thenAccept(v -> listeners.fire(v)); + future = future.thenAccept(v -> server.getClient().indexUpdated(IndexUpdatedParams.of(Set.of(project.getElementName())))).thenAccept(v -> listeners.fire(v)); this.latestScheduledTaskByProject.put(project.getElementName(), future); return future; @@ -460,7 +461,7 @@ public CompletableFuture createDocuments(String[] docURIs) { docURIs = unfold(docURIs); - List affectedProjects = new ArrayList<>(); + Set affectedProjects = new LinkedHashSet<>(); for (SpringIndexer indexer : this.indexers) { String[] interestingDocs = getDocumentsInterestingForIndexer(indexer, docURIs); @@ -523,7 +524,7 @@ public CompletableFuture updateDocument(String docURI, String content, Str synchronized(this) { List> futures = new ArrayList<>(); - List affectedProjects = new ArrayList<>(); + Set affectedProjects = new LinkedHashSet<>(); for (SpringIndexer indexer : this.indexers) { if (indexer.isInterestedIn(docURI)) { @@ -553,7 +554,7 @@ public CompletableFuture updateDocuments(String[] docURIs, String reason) synchronized(this) { List> futures = new ArrayList<>(); - List affectedProjects = new ArrayList<>(); + Set affectedProjects = new LinkedHashSet<>(); for (SpringIndexer indexer : this.indexers) { String[] interestingDocs = getDocumentsInterestingForIndexer(indexer, docURIs); @@ -652,7 +653,7 @@ public CompletableFuture deleteDocuments(String[] deletedPathURIs) { synchronized(this) { try { List> futures = new ArrayList<>(); - List affectedProjects = new ArrayList<>(); + Set affectedProjects = new LinkedHashSet<>(); Map> projectMapping = getDocsPerProjectFromPaths(deletedPathURIs); for (IJavaProject project : projectMapping.keySet()) { diff --git a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/JsonNodeHandler.java b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/JsonNodeHandler.java index 1e104b4a46..eff6d2d929 100644 --- a/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/JsonNodeHandler.java +++ b/headless-services/spring-boot-language-server/src/main/java/org/springframework/ide/vscode/boot/java/commands/JsonNodeHandler.java @@ -218,7 +218,8 @@ private void addChild(Consumer consumer) { } private static void assignNodeId(Node n, Node p) { - String textId = n.attributes.containsKey(TEXT) ? (String) n.attributes.get(TEXT) : ""; + String textId = n.attributes.containsKey(PROJECT_ID) ? (String) n.attributes.get(PROJECT_ID) + : n.attributes.containsKey(TEXT) ? (String) n.attributes.get(TEXT) : ""; Location location = (Location) n.attributes.get(LOCATION); String locationId = location == null ? "" : "%s:%d:%d".formatted(location.getUri(), location.getRange().getStart().getLine(), location.getRange().getStart().getCharacter()); diff --git a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java index aa623f2a9c..74eb74a328 100644 --- a/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java +++ b/headless-services/spring-boot-language-server/src/test/java/org/springframework/ide/vscode/boot/index/test/SpringMetamodelIndexingTest.java @@ -72,7 +72,7 @@ public void setup() throws Exception { void testUpdateNotificationAfterProjectCreation() { assertEquals(1, harness.getIndexUpdatedCount()); assertEquals(1, harness.getIndexUpdatedDetails(0).getAffectedProjects().size()); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().get(0)); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().iterator().next()); } @Test @@ -89,8 +89,8 @@ void testDeleteProject() throws Exception { assertEquals(2, harness.getIndexUpdatedCount()); // 1x project created, 1x project deleted assertEquals(1, harness.getIndexUpdatedDetails(0).getAffectedProjects().size()); assertEquals(1, harness.getIndexUpdatedDetails(1).getAffectedProjects().size()); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().get(0)); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().get(0)); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().iterator().next()); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().iterator().next()); } @Test @@ -118,8 +118,8 @@ void testRemoveSymbolsFromDeletedDocument() throws Exception { assertEquals(2, harness.getIndexUpdatedCount()); // 1x project created, 1x document deleted assertEquals(1, harness.getIndexUpdatedDetails(0).getAffectedProjects().size()); assertEquals(1, harness.getIndexUpdatedDetails(1).getAffectedProjects().size()); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().get(0)); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().get(0)); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().iterator().next()); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().iterator().next()); } @Test @@ -151,8 +151,8 @@ void testUpdateChangedDocument() throws Exception { assertEquals(2, harness.getIndexUpdatedCount()); // 1x project created, 1x document updated assertEquals(1, harness.getIndexUpdatedDetails(0).getAffectedProjects().size()); assertEquals(1, harness.getIndexUpdatedDetails(1).getAffectedProjects().size()); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().get(0)); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().get(0)); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().iterator().next()); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().iterator().next()); } @Test @@ -214,8 +214,8 @@ void testNewDocumentCreated() throws Exception { assertEquals(2, harness.getIndexUpdatedCount()); // 1x project created, 1x new document created assertEquals(1, harness.getIndexUpdatedDetails(0).getAffectedProjects().size()); assertEquals(1, harness.getIndexUpdatedDetails(1).getAffectedProjects().size()); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().get(0)); - assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().get(0)); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(0).getAffectedProjects().iterator().next()); + assertEquals("test-spring-indexing", harness.getIndexUpdatedDetails(1).getAffectedProjects().iterator().next()); } finally { FileUtils.deleteQuietly(new File(new URI(createdDocURI))); diff --git a/vscode-extensions/vscode-spring-boot/lib/api.d.ts b/vscode-extensions/vscode-spring-boot/lib/api.d.ts index 13bea525d8..25e248e784 100644 --- a/vscode-extensions/vscode-spring-boot/lib/api.d.ts +++ b/vscode-extensions/vscode-spring-boot/lib/api.d.ts @@ -1,6 +1,6 @@ import { Event, Uri } from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; -import { LiveProcess } from "./notification"; +import { IndexUpdateDetails, LiveProcess } from "./notification"; import {Location} from "vscode-languageclient"; export interface ExtensionAPI { @@ -109,9 +109,9 @@ interface InjectionPoint { interface SpringIndex { readonly beans: (params: BeansParams) => Promise; - readonly onSpringIndexUpdated: Event; + readonly onSpringIndexUpdated: Event; } interface BeansParams { projectName: string; -} \ No newline at end of file +} diff --git a/vscode-extensions/vscode-spring-boot/lib/apiManager.ts b/vscode-extensions/vscode-spring-boot/lib/apiManager.ts index 96009dd66b..6b05abced0 100644 --- a/vscode-extensions/vscode-spring-boot/lib/apiManager.ts +++ b/vscode-extensions/vscode-spring-boot/lib/apiManager.ts @@ -1,6 +1,6 @@ import { commands } from "vscode"; import { Emitter, LanguageClient } from "vscode-languageclient/node"; -import {Bean, BeansParams, ExtensionAPI} from "./api"; +import { Bean, BeansParams, ExtensionAPI } from "./api"; import { LiveProcess, LiveProcessConnectedNotification, @@ -9,6 +9,7 @@ import { LiveProcessGcPausesMetricsUpdatedNotification, LiveProcessMemoryMetricsUpdatedNotification, SpringIndexUpdatedNotification, + IndexUpdateDetails, } from "./notification"; import {RequestType} from "vscode-languageclient"; @@ -19,7 +20,7 @@ export class ApiManager { private onDidLiveProcessUpdateEmitter: Emitter = new Emitter(); private onDidLiveProcessGcPausesMetricsUpdateEmitter: Emitter = new Emitter(); private onDidLiveProcessMemoryMetricsUpdateEmitter: Emitter = new Emitter(); - private onSpringIndexUpdateEmitter: Emitter = new Emitter(); + private onSpringIndexUpdateEmitter: Emitter = new Emitter(); public constructor(client: LanguageClient) { const onDidLiveProcessConnect = this.onDidLiveProcessConnectEmitter.event; @@ -61,7 +62,7 @@ export class ApiManager { client.onNotification(LiveProcessGcPausesMetricsUpdatedNotification.type, (process: LiveProcess) => this.onDidLiveProcessGcPausesMetricsUpdateEmitter.fire(process)); client.onNotification(LiveProcessMemoryMetricsUpdatedNotification.type, (process: LiveProcess) => this.onDidLiveProcessMemoryMetricsUpdateEmitter.fire(process)); - client.onNotification(SpringIndexUpdatedNotification.type, () => this.onSpringIndexUpdateEmitter.fire()); + client.onNotification(SpringIndexUpdatedNotification.type, (details: IndexUpdateDetails) => this.onSpringIndexUpdateEmitter.fire(details)); const beansRequestType = new RequestType('spring/index/beans'); const beans = (params: BeansParams) => { diff --git a/vscode-extensions/vscode-spring-boot/lib/explorer/explorer-tree-provider.ts b/vscode-extensions/vscode-spring-boot/lib/explorer/explorer-tree-provider.ts index 421d6e04cf..8aa73afe5c 100644 --- a/vscode-extensions/vscode-spring-boot/lib/explorer/explorer-tree-provider.ts +++ b/vscode-extensions/vscode-spring-boot/lib/explorer/explorer-tree-provider.ts @@ -1,72 +1,36 @@ -import { Event, EventEmitter, ExtensionContext, ProviderResult, TreeDataProvider, TreeItem, TreeItemCollapsibleState, window } from "vscode"; +import { Event, EventEmitter, ExtensionContext, ProviderResult, TreeDataProvider, TreeItem, window } from "vscode"; import { StructureManager } from "./structure-tree-manager"; -import { SpringNode } from "./nodes"; +import { StereotypedNode } from "./nodes"; -export class ExplorerTreeProvider implements TreeDataProvider { +export class ExplorerTreeProvider implements TreeDataProvider { - private emitter: EventEmitter; - public readonly onDidChangeTreeData: Event; - private expansionStates: Map = new Map(); + private emitter: EventEmitter; + public readonly onDidChangeTreeData: Event; constructor(private manager: StructureManager) { - this.emitter = new EventEmitter(); + this.emitter = new EventEmitter(); this.onDidChangeTreeData = this.emitter.event; - this.manager.onDidChange(e => { - // Expansion states are tracked via onDidExpandElement/onDidCollapseElement events - this.emitter.fire(e); - }); + this.manager.onDidChange(e => this.emitter.fire(e)); } createTreeView(context: ExtensionContext, viewId: string) { - const treeView = window.createTreeView(viewId, { treeDataProvider: this, showCollapseAll: true }); - - // Track expansion/collapse events to preserve state across refreshes - context.subscriptions.push(treeView.onDidExpandElement(e => { - const nodeId = e.element.getNodeId(); - this.setExpansionState(nodeId, TreeItemCollapsibleState.Expanded); - })); - - context.subscriptions.push(treeView.onDidCollapseElement(e => { - const nodeId = e.element.getNodeId(); - this.setExpansionState(nodeId, TreeItemCollapsibleState.Collapsed); - })); - + const treeView = window.createTreeView(viewId, { treeDataProvider: this, showCollapseAll: true }); context.subscriptions.push(treeView); return treeView } - getTreeItem(element: SpringNode): TreeItem | Thenable { - const nodeId = element.getNodeId(); - const savedState = this.expansionStates.get(nodeId); - return element.getTreeItem(savedState); + getTreeItem(element: StereotypedNode): TreeItem | Thenable { + return element.getTreeItem(); } - getChildren(element?: SpringNode): ProviderResult { + getChildren(element?: StereotypedNode): ProviderResult { if (element) { return element.children; } return this.getRootElements(); } - getRootElements(): ProviderResult { + getRootElements(): ProviderResult { return this.manager.rootElements; } - - - private getExpansionState(nodeId: string): TreeItemCollapsibleState | undefined { - return this.expansionStates.get(nodeId); - } - - private setExpansionState(nodeId: string, state: TreeItemCollapsibleState): void { - this.expansionStates.set(nodeId, state); - } - - // getParent?(element: SpringNode): ProviderResult { - // throw new Error("Method not implemented."); - // } - - // resolveTreeItem?(item: TreeItem, element: SpringNode, token: CancellationToken): ProviderResult { - // throw new Error("Method not implemented."); - // } - } \ No newline at end of file diff --git a/vscode-extensions/vscode-spring-boot/lib/explorer/nodes.ts b/vscode-extensions/vscode-spring-boot/lib/explorer/nodes.ts index eb5cb4538f..88cb3f8aaa 100644 --- a/vscode-extensions/vscode-spring-boot/lib/explorer/nodes.ts +++ b/vscode-extensions/vscode-spring-boot/lib/explorer/nodes.ts @@ -1,60 +1,26 @@ import { TextDocumentShowOptions, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from "vscode"; import { Location } from "vscode-languageclient"; import { LsStereoTypedNode } from "./structure-tree-manager"; +import * as ls from 'vscode-languageserver-protocol'; -export class SpringNode { - constructor(public children: SpringNode[], protected parent?: SpringNode) {} - - getTreeItem(savedState?: TreeItemCollapsibleState): TreeItem { - const defaultState = savedState !== undefined ? savedState : TreeItemCollapsibleState.Collapsed; - return new TreeItem("", this.computeState(defaultState)); - } - - computeState(defaultState: TreeItemCollapsibleState): TreeItemCollapsibleState { - return Array.isArray(this.children) && this.children.length ? defaultState : TreeItemCollapsibleState.None; - } - - getNodeId(): string { - return ""; - } - - protected getParentPath(): string { - if (!this.parent) { - return ""; - } - - const parentText = this.parent.getNodeText(); - // Recursively get the full path of all ancestors up to the root - const ancestorPath = this.parent.getParentPath(); - - return ancestorPath ? `${ancestorPath}/${parentText}` : parentText; - } - - protected getNodeText(): string { - return ""; - } -} - -export class StereotypedNode extends SpringNode { - constructor(private n: LsStereoTypedNode, children: SpringNode[], parent?: SpringNode) { - super(children, parent); - } +export class StereotypedNode { + constructor(private n: LsStereoTypedNode, public children: StereotypedNode[], protected parent?: StereotypedNode) {} getTreeItem(savedState?: TreeItemCollapsibleState): TreeItem { - const item = super.getTreeItem(savedState); - item.label = this.n.attributes.text; - item.iconPath = this.computeIcon(); + const defaultState = savedState !== undefined ? savedState : TreeItemCollapsibleState.Collapsed; + const item = new TreeItem(this.label, Array.isArray(this.children) && this.children.length ? defaultState : TreeItemCollapsibleState.None); + item.iconPath = new ThemeIcon(this.n.attributes.icon); + item.id = this.nodeId; // Add context value if reference attribute exists if (this.n.attributes.reference) { item.contextValue = "stereotypedNodeWithReference"; } - if (this.n.attributes.icon === 'project') { + if (this.projectId) { item.contextValue = "project"; } - if (this.n.attributes.location) { const location = this.n.attributes.location as Location; item.command = { @@ -68,24 +34,20 @@ export class StereotypedNode extends SpringNode { return item; } - getProjectId(): string { - return this.n.attributes.projectId || this.n.attributes.text; + get projectId(): string { + return this.n.attributes.projectId; } - getNodeId(): string { + get nodeId(): string { return this.n.attributes.nodeId || this.n.attributes.text; } - protected getNodeText(): string { + get label(): string { return this.n.attributes.text || ''; } - getReferenceValue(): any { - return this.n.attributes.reference; - } - - computeIcon() { - return new ThemeIcon(this.n.attributes.icon); + get referenceValue(): ls.Location | undefined { + return this.n.attributes.reference as ls.Location; } } diff --git a/vscode-extensions/vscode-spring-boot/lib/explorer/structure-tree-manager.ts b/vscode-extensions/vscode-spring-boot/lib/explorer/structure-tree-manager.ts index 7533c926a3..5bec6ce7b0 100644 --- a/vscode-extensions/vscode-spring-boot/lib/explorer/structure-tree-manager.ts +++ b/vscode-extensions/vscode-spring-boot/lib/explorer/structure-tree-manager.ts @@ -1,22 +1,27 @@ -import { commands, EventEmitter, Event, ExtensionContext, window, Memento, Uri, QuickPickItem } from "vscode"; -import { SpringNode, StereotypedNode } from "./nodes"; +import { commands, EventEmitter, Event, ExtensionContext, window, Memento, QuickPickItem } from "vscode"; +import { StereotypedNode } from "./nodes"; import { ExtensionAPI } from "../api"; -import * as ls from 'vscode-languageserver-protocol'; - const SPRING_STRUCTURE_CMD = "sts/spring-boot/structure"; +interface StructureCommandParams { + updateMetadata: boolean; + groups?: Record; + affectedProjects?: string[]; +} + export class StructureManager { - private _rootElements: Thenable - private _onDidChange: EventEmitter = new EventEmitter(); + private _rootElementsRequest: Thenable + private _rootElements: StereotypedNode[] = []; + private _onDidChange = new EventEmitter(); private workspaceState: Memento; constructor(context: ExtensionContext, api: ExtensionAPI) { this.workspaceState = context.workspaceState; context.subscriptions.push(commands.registerCommand("vscode-spring-boot.structure.refresh", () => this.refresh(true))); - context.subscriptions.push(commands.registerCommand("vscode-spring-boot.structure.openReference", (node) => { - const reference = node?.getReferenceValue() as ls.Location;; + context.subscriptions.push(commands.registerCommand("vscode-spring-boot.structure.openReference", (node: StereotypedNode) => { + const reference = node?.referenceValue; if (reference) { const location = api.client.protocol2CodeConverter.asLocation(reference) window.showTextDocument(location.uri, { selection: location.range }); @@ -24,7 +29,7 @@ export class StructureManager { })); context.subscriptions.push(commands.registerCommand("vscode-spring-boot.structure.grouping", async (node: StereotypedNode) => { - const projectName = node.getProjectId(); + const projectName = node.projectId; const groups = await commands.executeCommand("sts/spring-boot/structure/groups", projectName); const initialGroups: string[] | undefined = this.getVisibleGroups(projectName); const items = (groups?.groups || []).map(g => ({ @@ -45,38 +50,81 @@ export class StructureManager { } })); - context.subscriptions.push(api.getSpringIndex().onSpringIndexUpdated(e => this.refresh(false))); + context.subscriptions.push(api.getSpringIndex().onSpringIndexUpdated(indexUpdateDetails => this.refresh(false, indexUpdateDetails.affectedProjects))); } - get rootElements(): Thenable { - return this._rootElements; + get rootElements(): Thenable { + return this._rootElementsRequest; } - refresh(updateMetadata: boolean): void { - this._rootElements = commands.executeCommand(SPRING_STRUCTURE_CMD, - { - "updateMetadata" : updateMetadata, - "groups" : this.getGroupings() - }).then(json => { + // Serves 2 purposes: non UI triggered refresh as a result of the index update and a UI triggered refresh + // The UI triggered refresh needs to proceed with an event fired such that tree view would kick off a new promise getting all new root elements and would show progress while promise is being resolved. + // The index update typically would have a list of projects for which index has changed then the refresh can be silent with letting the tree know about new data once it is computed + // If the index update event doesn't have a list of project then this is an edge case for which we'd show the preogress and treat it like UI triggered refresh + refresh(updateMetadata: boolean, affectedProjects?: string[]): void { + const isPartialLoad = !!(affectedProjects && affectedProjects.length); + // Notify the tree to get the children to trigger "loading" bar in the view??? + const params = { + updateMetadata, + affectedProjects, + groups: this.getGroupings(), + } as StructureCommandParams; + this._rootElementsRequest = commands.executeCommand(SPRING_STRUCTURE_CMD, params).then(json => { const nodes = this.parseArray(json); - this._onDidChange.fire(undefined); - return nodes; + if (isPartialLoad) { + const newNodes = [] as StereotypedNode[]; + const nodesMap = {} as Record; + affectedProjects.forEach(projectName => nodesMap[projectName] = nodes.find(n => n.projectId === projectName)); + // merge old and newly fetched stereotype root nodes + let onlyMutations = true; + this._rootElements.forEach(n => { + if (nodesMap.hasOwnProperty(n.projectId)) { + const newN = nodesMap[n.projectId]; + delete nodesMap[n.projectId]; + if (newN) { + newNodes.push(newN); + } else { + // element removed + onlyMutations = false; + } + } else { + newNodes.push(n); + } + }); + if (Object.values(nodesMap).length) { + // elements added + onlyMutations = false; + Object.values(nodesMap).filter(n => !!n).forEach(n => newNodes.push(n)); + } + this._rootElements = newNodes; + // TODO: Partial tree refresh didn't work for restbucks it remains either without children or without the full text label + // (test with `spring-restbucks` project in a workspace with other boot projects, i.e. demo, spring-petclinic) + this._onDidChange.fire(/*onlyMutations ? nodes : */undefined); + } else { + this._rootElements = nodes; + // No need to fire another event to update the UI since there is an event fired before refresh is triggered to reference the new promise + } + return this._rootElements; }); + if (!isPartialLoad) { + // Fire an event for full reload to have a progress bar while the promise above is resolved + this._onDidChange.fire(undefined); + } } - private parseNode(json: any, parent?: SpringNode): SpringNode | undefined { + private parseNode(json: any, parent?: StereotypedNode): StereotypedNode | undefined { const node = new StereotypedNode(json as LsStereoTypedNode, [], parent); // Parse children after creating the node so we can pass it as parent node.children.push(...this.parseArray(json.children, node)); return node; } - private parseArray(json: any, parent?: SpringNode): SpringNode[] { + private parseArray(json: any, parent?: StereotypedNode): StereotypedNode[] { return Array.isArray(json) ? (json as []).map(j => this.parseNode(j, parent)).filter(e => !!e) : []; } - public get onDidChange(): Event { + public get onDidChange(): Event { return this._onDidChange.event; } diff --git a/vscode-extensions/vscode-spring-boot/lib/notification.ts b/vscode-extensions/vscode-spring-boot/lib/notification.ts index 2f01718195..c8fb526467 100644 --- a/vscode-extensions/vscode-spring-boot/lib/notification.ts +++ b/vscode-extensions/vscode-spring-boot/lib/notification.ts @@ -30,6 +30,10 @@ export interface LocalLiveProcess extends LiveProcess { pid: string } +export interface IndexUpdateDetails { + affectedProjects?: string[] +} + export namespace LiveProcessConnectedNotification { export const type = new NotificationType('sts/liveprocess/connected'); } @@ -51,7 +55,7 @@ export namespace LiveProcessMemoryMetricsUpdatedNotification { } export namespace SpringIndexUpdatedNotification { - export const type = new NotificationType('spring/index/updated'); + export const type = new NotificationType('spring/index/updated'); } export namespace LiveProcessLogLevelUpdatedNotification {