Skip to content

Commit 4d1b6f4

Browse files
committed
Show Open/Close Project for mixed selections in Project Explorer
Ctrl+A in an expanded Project Explorer produces a mixed selection: projects, child files/folders, and non-adaptable elements such as working set headers. Three problems combined to hide the Open/Close Project actions in that case: 1. The ResourceMgmtActionProvider enablement expression required ALL selected elements to adapt to IResource or IWorkingSet, so the provider was never activated for mixed selections. Fixed by replacing the expression with <or/> to always activate the provider, following the UndoRedoActionProvider pattern. fillContextMenu() only adds the actions when the selection contains a project. 2. CloseResourceAction and OpenResourceAction.updateSelection() called selectionIsOfType(PROJECT), which returns false whenever any non-IResource element (e.g. a working set header) is present, even if every resource element is a valid project. Fixed by filtering getSelectedResources() down to IProject instances instead. 3. CloseUnrelatedProjectsAction.resourceChanged() had the same selectionIsOfType guard. Fixed with a stream-based project check. The actions also override getActionResources() to keep only IProject instances, so the execution paths (run() and the scheduling rule in CloseResourceAction, hasOtherClosedProjects() in OpenResourceAction) no longer cast a non-project resource to IProject for a mixed selection. getSelectedResources() stays unfiltered so CloseUnrelatedProjectsAction still keeps a selected file's project related. Adds navigator tests covering mixed selections for both enablement and for reducing the selection to projects only before the action runs. Fixes #3790
1 parent f22f75c commit 4d1b6f4

5 files changed

Lines changed: 178 additions & 21 deletions

File tree

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
package org.eclipse.ui.actions;
1717

1818
import java.util.ArrayList;
19-
import java.util.Iterator;
2019
import java.util.List;
2120

2221
import org.eclipse.core.resources.IFile;
@@ -179,8 +178,9 @@ protected void invokeOperation(IResource resource, IProgressMonitor monitor) thr
179178
*/
180179
@Override
181180
public void run() {
182-
// Get the items to close.
183-
List<? extends IResource> projects = getSelectedResources();
181+
// Get the items to close (only projects: a mixed selection, e.g. Ctrl+A,
182+
// may also contain files or non-resource elements).
183+
List<? extends IResource> projects = getActionResources();
184184
if (projects == null || projects.isEmpty()) {
185185
// no action needs to be taken since no projects are selected
186186
return;
@@ -229,14 +229,14 @@ protected boolean updateSelection(IStructuredSelection s) {
229229
// don't call super since we want to enable if open project is selected.
230230
setText(defaultText);
231231
setToolTipText(defaultToolTip);
232-
if (!selectionIsOfType(IResource.PROJECT)) {
232+
List<IProject> projects = getSelectedResources().stream()
233+
.filter(IProject.class::isInstance).map(IProject.class::cast).toList();
234+
if (projects.isEmpty()) {
233235
return false;
234236
}
235237

236238
boolean hasOpenProjects = false;
237-
Iterator<? extends IResource> resources = getSelectedResources().iterator();
238-
while (resources.hasNext()) {
239-
IProject currentResource = (IProject) resources.next();
239+
for (IProject currentResource : projects) {
240240
if (currentResource.isOpen()) {
241241
if (hasOpenProjects) {
242242
setText(pluralText);
@@ -258,7 +258,7 @@ public synchronized void resourceChanged(IResourceChangeEvent event) {
258258
// Warning: code duplicated in OpenResourceAction
259259
List<? extends IResource> sel = getSelectedResources();
260260
// don't bother looking at delta if selection not applicable
261-
if (selectionIsOfType(IResource.PROJECT)) {
261+
if (sel.stream().anyMatch(IProject.class::isInstance)) {
262262
IResourceDelta delta = event.getDelta();
263263
if (delta != null) {
264264
IResourceDelta[] projDeltas = delta.getAffectedChildren(IResourceDelta.CHANGED);
@@ -280,6 +280,13 @@ protected synchronized List<? extends IResource> getSelectedResources() {
280280
return super.getSelectedResources();
281281
}
282282

283+
@Override
284+
protected List<? extends IResource> getActionResources() {
285+
// The close operation only ever applies to projects; drop any non-project
286+
// elements of a mixed selection so execution does not fail with a cast.
287+
return super.getActionResources().stream().filter(IProject.class::isInstance).toList();
288+
}
289+
283290
@Override
284291
protected synchronized List<?> getSelectedNonResources() {
285292
return super.getSelectedNonResources();

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ protected List<? extends IResource> getSelectedResources() {
261261
@Override
262262
public void resourceChanged(IResourceChangeEvent event) {
263263
// don't bother looking at delta if selection not applicable
264-
if (selectionIsOfType(IResource.PROJECT)) {
264+
List<? extends IResource> selectedResources = super.getSelectedResources();
265+
if (selectedResources.stream().anyMatch(IProject.class::isInstance)) {
265266
IResourceDelta delta = event.getDelta();
266267
if (delta != null) {
267268
IResourceDelta[] projDeltas = delta.getAffectedChildren(IResourceDelta.CHANGED);

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ protected String getProblemsTitle() {
139139
private boolean hasOtherClosedProjects() {
140140
//count the closed projects in the selection
141141
int closedInSelection = 0;
142-
for (IResource project : getSelectedResources()) {
142+
for (IResource project : getActionResources()) {
143143
if (!((IProject) project).isOpen()) {
144144
closedInSelection++;
145145
}
@@ -154,6 +154,13 @@ protected void invokeOperation(IResource resource, IProgressMonitor monitor) thr
154154
((IProject) resource).open(IResource.BACKGROUND_REFRESH, monitor);
155155
}
156156

157+
@Override
158+
protected List<? extends IResource> getActionResources() {
159+
// The open operation only ever applies to projects; drop any non-project
160+
// elements of a mixed selection so execution does not fail with a cast.
161+
return super.getActionResources().stream().filter(IProject.class::isInstance).toList();
162+
}
163+
157164
/**
158165
* Returns the preference for whether to open required projects when opening
159166
* a project. Consults the preference and prompts the user if necessary.
@@ -190,7 +197,7 @@ public void resourceChanged(IResourceChangeEvent event) {
190197
// Warning: code duplicated in CloseResourceAction
191198
List<? extends IResource> sel = getSelectedResources();
192199
// don't bother looking at delta if selection not applicable
193-
if (selectionIsOfType(IResource.PROJECT)) {
200+
if (sel.stream().anyMatch(IProject.class::isInstance)) {
194201
IResourceDelta delta = event.getDelta();
195202
if (delta != null) {
196203
IResourceDelta[] projDeltas = delta.getAffectedChildren(IResourceDelta.CHANGED);
@@ -304,13 +311,15 @@ protected boolean updateSelection(IStructuredSelection s) {
304311
// selected.
305312
setText(IDEWorkbenchMessages.OpenResourceAction_text);
306313
setToolTipText(IDEWorkbenchMessages.OpenResourceAction_toolTip);
307-
if (!selectionIsOfType(IResource.PROJECT)) {
314+
List<IProject> projects = getSelectedResources().stream()
315+
.filter(IProject.class::isInstance).map(IProject.class::cast).toList();
316+
if (projects.isEmpty()) {
308317
return false;
309318
}
310319

311320
boolean hasClosedProjects = false;
312-
for (IResource currentResource : getSelectedResources()) {
313-
if (!((IProject) currentResource).isOpen()) {
321+
for (IProject currentResource : projects) {
322+
if (!currentResource.isOpen()) {
314323
if (hasClosedProjects) {
315324
setText(IDEWorkbenchMessages.OpenResourceAction_text_plural);
316325
setToolTipText(IDEWorkbenchMessages.OpenResourceAction_toolTip_plural);

bundles/org.eclipse.ui.navigator.resources/plugin.xml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -237,13 +237,8 @@
237237
class="org.eclipse.ui.internal.navigator.resources.actions.ResourceMgmtActionProvider"
238238
id="org.eclipse.ui.navigator.resources.ResourceMgmtActions">
239239
<enablement>
240-
<or>
241-
<adapt type="org.eclipse.core.resources.IResource" />
242-
<adapt type="java.util.Collection">
243-
<count value="0" />
244-
</adapt>
245-
<adapt type="org.eclipse.ui.IWorkingSet" />
246-
</or>
240+
<!-- Always enabled: fillContextMenu() filters to applicable projects -->
241+
<or/>
247242
</enablement>
248243
</actionProvider>
249244

tests/org.eclipse.ui.tests.navigator/src/org/eclipse/ui/tests/navigator/resources/ResourceMgmtActionProviderTests.java

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
*******************************************************************************/
1111
package org.eclipse.ui.tests.navigator.resources;
1212

13+
import static org.junit.jupiter.api.Assertions.assertEquals;
1314
import static org.junit.jupiter.api.Assertions.assertTrue;
1415
import static org.junit.jupiter.api.Assertions.fail;
1516

17+
import java.util.List;
18+
1619
import org.eclipse.core.resources.ICommand;
20+
import org.eclipse.core.resources.IFile;
1721
import org.eclipse.core.resources.IFolder;
1822
import org.eclipse.core.resources.IProject;
1923
import org.eclipse.core.resources.IProjectDescription;
@@ -28,6 +32,8 @@
2832
import org.eclipse.jface.action.MenuManager;
2933
import org.eclipse.jface.viewers.StructuredSelection;
3034
import org.eclipse.ui.actions.ActionContext;
35+
import org.eclipse.ui.actions.CloseResourceAction;
36+
import org.eclipse.ui.actions.OpenResourceAction;
3137
import org.eclipse.ui.internal.navigator.NavigatorContentService;
3238
import org.eclipse.ui.internal.navigator.extensions.CommonActionExtensionSite;
3339
import org.eclipse.ui.internal.navigator.resources.actions.ResourceMgmtActionProvider;
@@ -118,6 +124,120 @@ public void testFillContextMenu_openProjectNoBuilderSelection() throws CoreExcep
118124
}
119125
}
120126

127+
/**
128+
* Test for a file selected together with an open project: Close Project must
129+
* be both present and enabled. Regression test for the bug where
130+
* selectionIsOfType(PROJECT) disabled the action for any mixed selection.
131+
*
132+
* @throws CoreException
133+
*/
134+
@Test
135+
public void testFillContextMenu_fileAndOpenProjectSelection_closeProjectEnabled() throws CoreException {
136+
// _p1 is already open; _project has a known 'src' folder + files
137+
IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test");
138+
openProj.open(null);
139+
// Select a file alongside a project (the typical Ctrl+A expanded scenario)
140+
ResourceMgmtActionProvider provider = providerForObjects(_p1, openProj.getFile(".project"));
141+
provider.fillContextMenu(manager);
142+
assertTrue(menuHasContribution("org.eclipse.ui.CloseResourceAction"),
143+
"Close Project should be in the menu");
144+
assertTrue(isMenuContributionEnabled("org.eclipse.ui.CloseResourceAction"),
145+
"Close Project should be enabled when open projects are in the selection");
146+
assertTrue(menuHasContribution("org.eclipse.ui.CloseUnrelatedProjectsAction"),
147+
"Close Unrelated Projects should be in the menu");
148+
assertTrue(isMenuContributionEnabled("org.eclipse.ui.CloseUnrelatedProjectsAction"),
149+
"Close Unrelated Projects should be enabled when open projects are in the selection");
150+
}
151+
152+
/**
153+
* Test for mixed selection: an open project alongside a non-adaptable element
154+
* (e.g. a working set header from Ctrl+A in Project Explorer). Close Project
155+
* and Refresh must still appear — regression test for issue #3790.
156+
*
157+
* @throws CoreException
158+
*/
159+
@Test
160+
public void testFillContextMenu_mixedSelectionOpenProjectAndNonAdaptableElement() throws CoreException {
161+
IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test");
162+
openProj.open(null);
163+
// Plain Object does not implement IAdaptable, so it is never resolved to a
164+
// project — it counts as a non-project element in the selection.
165+
Object nonResource = new Object();
166+
ResourceMgmtActionProvider provider = providerForObjects(openProj, nonResource);
167+
provider.fillContextMenu(manager);
168+
checkMenuHasCorrectContributions(false, true, false, true, true);
169+
}
170+
171+
/**
172+
* Test for a fully expanded selection: two open projects plus child resources
173+
* from both (simulating Ctrl+A when both projects are expanded). Close Project
174+
* must still appear for the open projects in the selection.
175+
*
176+
* @throws CoreException
177+
*/
178+
@Test
179+
public void testFillContextMenu_twoOpenProjectsWithChildResourcesSelection() throws CoreException {
180+
// _p1 and _p2 are already opened in setUp()
181+
IFolder srcFolder = _project.getFolder("src");
182+
IFolder binFolder = _project.getFolder("bin");
183+
ResourceMgmtActionProvider provider = providerForObjects(_p1, _p2, srcFolder, binFolder);
184+
provider.fillContextMenu(manager);
185+
checkMenuHasCorrectContributions(false, true, false, true, true);
186+
}
187+
188+
/**
189+
* Regression test for the ClassCastException that the always-on provider
190+
* enablement could expose: when Close Project is invoked on a mixed selection
191+
* (project plus a file plus a non-resource element, as produced by Ctrl+A),
192+
* the action must reduce the selection to projects only. Otherwise run() casts
193+
* every selected resource to IProject while building the scheduling rule.
194+
*
195+
* @throws CoreException
196+
*/
197+
@Test
198+
public void testCloseResourceAction_actionResourcesContainProjectsOnly() throws CoreException {
199+
IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test");
200+
openProj.open(null);
201+
IFile projectFile = openProj.getFile(".project");
202+
StructuredSelection mixed = new StructuredSelection(new Object[] { openProj, projectFile, new Object() });
203+
204+
var action = new CloseResourceAction(() -> _commonNavigator.getViewSite().getShell()) {
205+
List<? extends IResource> exposedActionResources() {
206+
return getActionResources();
207+
}
208+
};
209+
action.selectionChanged(mixed);
210+
211+
assertEquals(List.of(openProj), action.exposedActionResources(),
212+
"Close Project must operate on projects only, not files or non-resource elements");
213+
}
214+
215+
/**
216+
* Regression test for the ClassCastException in OpenResourceAction on a mixed
217+
* selection: the action must reduce the selection to projects only, otherwise
218+
* hasOtherClosedProjects() casts a non-project resource to IProject while
219+
* opening projects with their references.
220+
*
221+
* @throws CoreException
222+
*/
223+
@Test
224+
public void testOpenResourceAction_actionResourcesContainProjectsOnly() throws CoreException {
225+
IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test");
226+
openProj.open(null);
227+
IFile projectFile = openProj.getFile(".project");
228+
StructuredSelection mixed = new StructuredSelection(new Object[] { openProj, projectFile, new Object() });
229+
230+
var action = new OpenResourceAction(() -> _commonNavigator.getViewSite().getShell()) {
231+
List<? extends IResource> exposedActionResources() {
232+
return getActionResources();
233+
}
234+
};
235+
action.selectionChanged(mixed);
236+
237+
assertEquals(List.of(openProj), action.exposedActionResources(),
238+
"Open Project must operate on projects only, not files or non-resource elements");
239+
}
240+
121241
/**
122242
* Test for 'open project' that doesn't have a builder attached - only 'open
123243
* project' should be disabled
@@ -158,6 +278,19 @@ public void testFillContextMenu_openProjectWithBuilderSelection() throws CoreExc
158278
}
159279
}
160280

281+
/*
282+
* Return a provider for a mixed/arbitrary selection (Object[])
283+
*/
284+
private ResourceMgmtActionProvider providerForObjects(Object... selectedElements) {
285+
ICommonActionExtensionSite cfg = new CommonActionExtensionSite("NA", "NA",
286+
CommonViewerSiteFactory.createCommonViewerSite(_commonNavigator.getViewSite()),
287+
(NavigatorContentService) _contentService, _viewer);
288+
ResourceMgmtActionProvider provider = new ResourceMgmtActionProvider();
289+
provider.setContext(new ActionContext(new StructuredSelection(selectedElements)));
290+
provider.init(cfg);
291+
return provider;
292+
}
293+
161294
/*
162295
* Return a provider, given the selected navigator items
163296
*/
@@ -206,4 +339,16 @@ private boolean menuHasContribution(String contribution) {
206339
return false;
207340
}
208341

342+
/*
343+
* Check whether the named menu entry is enabled
344+
*/
345+
private boolean isMenuContributionEnabled(String contribution) {
346+
for (IContributionItem thisItem : manager.getItems()) {
347+
if (thisItem.getId() != null && thisItem.getId().equals(contribution)) {
348+
return thisItem.isEnabled();
349+
}
350+
}
351+
return false;
352+
}
353+
209354
}

0 commit comments

Comments
 (0)