Skip to content

Commit de99193

Browse files
committed
Fix CloseUnrelatedProjectsAction enablement after closing all unrelated projects
After PR #3871, updateSelection() only checked whether the user selection contained an open project, so the action stayed enabled even when no unrelated projects were left to close. PR #3871 was needed because the prior check was triggering buildConnectedComponents() on every selection change, which calls IProject#getReferencedProjects() across the workspace and can resolve classpath containers, blocking the IDE. Cache the workspace project graph and invalidate it from resourceChanged() when project open state or description changes. The expensive graph build now runs at most once per project-state change rather than once per selection event, while updateSelection() can again ask whether any open unrelated project actually exists. Selection-time work is reduced to a non-mutating scan of the cached graph. Add CloseUnrelatedProjectsActionEnablementTest covering the regression scenario reported on PR #3871: A and B linked, C unrelated; after closing C, selecting B must disable the action.
1 parent 284a9f8 commit de99193

3 files changed

Lines changed: 153 additions & 12 deletions

File tree

bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseUnrelatedProjectsAction.java

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919

2020
import java.util.ArrayList;
2121
import java.util.Collections;
22+
import java.util.HashSet;
2223
import java.util.List;
24+
import java.util.Set;
2325

2426
import org.eclipse.core.resources.IProject;
2527
import org.eclipse.core.resources.IResource;
@@ -67,6 +69,10 @@ public class CloseUnrelatedProjectsAction extends CloseResourceAction {
6769

6870
private List<? extends IResource> oldSelection = Collections.emptyList();
6971

72+
// Cached workspace project graph. Built lazily, reused across selection
73+
// changes, invalidated when project open state or description changes.
74+
private DisjointSet<IProject> projectGraph;
75+
7076

7177
/**
7278
* Builds the connected component set for the input projects.
@@ -131,18 +137,23 @@ public CloseUnrelatedProjectsAction(IShellProvider provider){
131137
initAction();
132138
}
133139

134-
/**
135-
* Overrides to avoid calling the expensive
136-
* {@code computeRelated(List)} during selection changes. Uses only
137-
* the raw selection to determine enablement.
138-
*/
139140
@Override
140141
protected boolean updateSelection(IStructuredSelection s) {
141142
selectionDirty = true;
142143
if (!selectionIsOfType(IResource.PROJECT)) {
143144
return false;
144145
}
146+
boolean hasOpenSelectedProject = false;
145147
for (IResource resource : super.getSelectedResources()) {
148+
if (resource instanceof IProject project && project.isOpen()) {
149+
hasOpenSelectedProject = true;
150+
break;
151+
}
152+
}
153+
if (!hasOpenSelectedProject) {
154+
return false;
155+
}
156+
for (IResource resource : getSelectedResources()) {
146157
if (resource instanceof IProject project && project.isOpen()) {
147158
return true;
148159
}
@@ -217,27 +228,50 @@ protected void clearCache() {
217228
}
218229

219230
/**
220-
* Computes the related projects of the selection.
231+
* Computes the projects unrelated to the given selection.
221232
*/
222233
private List<IResource> computeRelated(List<? extends IResource> selection) {
223234
if (selection.contains(ResourcesPlugin.getWorkspace().getRoot())) {
224235
return new ArrayList<>();
225236
}
226-
//build the connected component set for all projects in the workspace
227-
DisjointSet<IProject> set = buildConnectedComponents(ResourcesPlugin.getWorkspace().getRoot().getProjects());
228-
//remove the connected components that the selected projects are in
237+
DisjointSet<IProject> set = getProjectGraph();
238+
Set<IProject> excludedRoots = new HashSet<>();
229239
for (IResource resource : selection) {
230240
IProject project = resource.getProject();
231241
if (project != null) {
232-
set.removeSet(project);
242+
IProject root = set.findSet(project);
243+
if (root != null) {
244+
excludedRoots.add(root);
245+
}
233246
}
234247
}
235-
//the remainder of the projects in the disjoint set are unrelated to the selection
248+
List<IProject> all = new ArrayList<>();
249+
set.toList(all);
236250
List<IResource> projects = new ArrayList<>();
237-
set.toList(projects);
251+
for (IProject project : all) {
252+
IProject root = set.findSet(project);
253+
if (root != null && !excludedRoots.contains(root)) {
254+
projects.add(project);
255+
}
256+
}
238257
return projects;
239258
}
240259

260+
private DisjointSet<IProject> getProjectGraph() {
261+
DisjointSet<IProject> graph = projectGraph;
262+
if (graph == null) {
263+
graph = buildConnectedComponents(ResourcesPlugin.getWorkspace().getRoot().getProjects());
264+
projectGraph = graph;
265+
}
266+
return graph;
267+
}
268+
269+
private void invalidateProjectGraph() {
270+
projectGraph = null;
271+
oldSelection = Collections.emptyList();
272+
selectionDirty = true;
273+
}
274+
241275
@Override
242276
protected List<? extends IResource> getSelectedResources() {
243277
if (selectionDirty) {
@@ -268,6 +302,7 @@ public void resourceChanged(IResourceChangeEvent event) {
268302
for (IResourceDelta projDelta : projDeltas) {
269303
//changing either the description or the open state can affect enablement
270304
if ((projDelta.getFlags() & (IResourceDelta.OPEN | IResourceDelta.DESCRIPTION)) != 0) {
305+
invalidateProjectGraph();
271306
selectionChanged(getStructuredSelection());
272307
return;
273308
}

tests/org.eclipse.ui.tests.navigator/src/org/eclipse/ui/tests/navigator/NavigatorTestSuite.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import org.eclipse.ui.tests.navigator.cdt.CdtTest;
2323
import org.eclipse.ui.tests.navigator.jst.JstPipelineTest;
24+
import org.eclipse.ui.tests.navigator.resources.CloseUnrelatedProjectsActionEnablementTest;
2425
import org.eclipse.ui.tests.navigator.resources.FoldersAsProjectsContributionTest;
2526
import org.eclipse.ui.tests.navigator.resources.NestedResourcesTests;
2627
import org.eclipse.ui.tests.navigator.resources.PathComparatorTest;
@@ -36,6 +37,7 @@
3637
LabelProviderTest.class, SorterTest.class, ViewerTest.class, CdtTest.class, M12Tests.class,
3738
FirstClassM1Tests.class, LinkHelperTest.class, ShowInTest.class, ResourceTransferTest.class,
3839
EvaluationCacheTest.class, ResourceMgmtActionProviderTests.class,
40+
CloseUnrelatedProjectsActionEnablementTest.class,
3941
NestedResourcesTests.class, PathComparatorTest.class, FoldersAsProjectsContributionTest.class,
4042
GoBackForwardsTest.class, CopyPasteActionTest.class
4143
// DnDTest.class, // DnDTest.testSetDragOperation() fails
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2026 Lars Vogel and others.
3+
*
4+
* This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* which accompanies this distribution, and is available at
7+
* https://www.eclipse.org/legal/epl-2.0/
8+
*
9+
* SPDX-License-Identifier: EPL-2.0
10+
*******************************************************************************/
11+
package org.eclipse.ui.tests.navigator.resources;
12+
13+
import static org.junit.jupiter.api.Assertions.assertFalse;
14+
import static org.junit.jupiter.api.Assertions.assertTrue;
15+
16+
import org.eclipse.core.resources.IProject;
17+
import org.eclipse.core.resources.IProjectDescription;
18+
import org.eclipse.core.resources.IWorkspace;
19+
import org.eclipse.core.resources.ResourcesPlugin;
20+
import org.eclipse.core.runtime.CoreException;
21+
import org.eclipse.jface.viewers.StructuredSelection;
22+
import org.eclipse.swt.widgets.Display;
23+
import org.eclipse.swt.widgets.Shell;
24+
import org.eclipse.ui.actions.CloseUnrelatedProjectsAction;
25+
import org.junit.jupiter.api.AfterEach;
26+
import org.junit.jupiter.api.BeforeEach;
27+
import org.junit.jupiter.api.Test;
28+
29+
public class CloseUnrelatedProjectsActionEnablementTest {
30+
31+
private IProject a;
32+
private IProject b;
33+
private IProject c;
34+
private Shell shell;
35+
36+
@BeforeEach
37+
public void setUp() throws CoreException {
38+
IWorkspace ws = ResourcesPlugin.getWorkspace();
39+
long suffix = System.nanoTime();
40+
a = ws.getRoot().getProject("CUPA_A_" + suffix);
41+
b = ws.getRoot().getProject("CUPA_B_" + suffix);
42+
c = ws.getRoot().getProject("CUPA_C_" + suffix);
43+
a.create(null);
44+
a.open(null);
45+
b.create(null);
46+
b.open(null);
47+
c.create(null);
48+
c.open(null);
49+
50+
IProjectDescription aDesc = a.getDescription();
51+
aDesc.setReferencedProjects(new IProject[] { b });
52+
a.setDescription(aDesc, null);
53+
54+
shell = new Shell(Display.getDefault());
55+
}
56+
57+
@AfterEach
58+
public void tearDown() throws CoreException {
59+
if (shell != null && !shell.isDisposed()) {
60+
shell.dispose();
61+
}
62+
for (IProject p : new IProject[] { a, b, c }) {
63+
if (p != null && p.exists()) {
64+
p.delete(true, true, null);
65+
}
66+
}
67+
}
68+
69+
@Test
70+
public void testDisabledAfterAllUnrelatedProjectsClosed() throws CoreException {
71+
CloseUnrelatedProjectsAction action = new CloseUnrelatedProjectsAction(() -> shell);
72+
73+
action.selectionChanged(new StructuredSelection(a));
74+
assertTrue(action.isEnabled(),
75+
"action must be enabled while unrelated open project C exists");
76+
77+
c.close(null);
78+
79+
action.selectionChanged(new StructuredSelection(b));
80+
assertFalse(action.isEnabled(),
81+
"action must be disabled when no unrelated open project remains");
82+
}
83+
84+
@Test
85+
public void testEnabledWhenUnrelatedOpenProjectExists() {
86+
CloseUnrelatedProjectsAction action = new CloseUnrelatedProjectsAction(() -> shell);
87+
88+
action.selectionChanged(new StructuredSelection(a));
89+
assertTrue(action.isEnabled(), "expected enabled when unrelated open project C exists");
90+
91+
action.selectionChanged(new StructuredSelection(b));
92+
assertTrue(action.isEnabled(),
93+
"expected enabled when unrelated open project C exists (selection B)");
94+
}
95+
96+
@Test
97+
public void testDisabledWhenSelectionCoversAllOpenProjects() throws CoreException {
98+
c.close(null);
99+
CloseUnrelatedProjectsAction action = new CloseUnrelatedProjectsAction(() -> shell);
100+
action.selectionChanged(new StructuredSelection(new Object[] { a, b }));
101+
assertFalse(action.isEnabled(),
102+
"action must be disabled when selection plus its references covers all open projects");
103+
}
104+
}

0 commit comments

Comments
 (0)