From 7d630a11917c1cdc47e25f8f0f44b6b38874d635 Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Mon, 1 Jun 2026 11:43:04 +0200 Subject: [PATCH 1/2] 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 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 https://github.com/eclipse-platform/eclipse.platform.ui/issues/3790 --- .../ui/actions/CloseResourceAction.java | 23 ++- .../actions/CloseUnrelatedProjectsAction.java | 3 +- .../ui/actions/OpenResourceAction.java | 19 ++- .../plugin.xml | 9 +- .../ResourceMgmtActionProviderTests.java | 145 ++++++++++++++++++ 5 files changed, 178 insertions(+), 21 deletions(-) diff --git a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseResourceAction.java b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseResourceAction.java index fd6aee00f48..25748b2f443 100644 --- a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseResourceAction.java +++ b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseResourceAction.java @@ -16,7 +16,6 @@ package org.eclipse.ui.actions; import java.util.ArrayList; -import java.util.Iterator; import java.util.List; import org.eclipse.core.resources.IFile; @@ -179,8 +178,9 @@ protected void invokeOperation(IResource resource, IProgressMonitor monitor) thr */ @Override public void run() { - // Get the items to close. - List projects = getSelectedResources(); + // Get the items to close (only projects: a mixed selection, e.g. Ctrl+A, + // may also contain files or non-resource elements). + List projects = getActionResources(); if (projects == null || projects.isEmpty()) { // no action needs to be taken since no projects are selected return; @@ -229,14 +229,14 @@ protected boolean updateSelection(IStructuredSelection s) { // don't call super since we want to enable if open project is selected. setText(defaultText); setToolTipText(defaultToolTip); - if (!selectionIsOfType(IResource.PROJECT)) { + List projects = getSelectedResources().stream() + .filter(IProject.class::isInstance).map(IProject.class::cast).toList(); + if (projects.isEmpty()) { return false; } boolean hasOpenProjects = false; - Iterator resources = getSelectedResources().iterator(); - while (resources.hasNext()) { - IProject currentResource = (IProject) resources.next(); + for (IProject currentResource : projects) { if (currentResource.isOpen()) { if (hasOpenProjects) { setText(pluralText); @@ -258,7 +258,7 @@ public synchronized void resourceChanged(IResourceChangeEvent event) { // Warning: code duplicated in OpenResourceAction List sel = getSelectedResources(); // don't bother looking at delta if selection not applicable - if (selectionIsOfType(IResource.PROJECT)) { + if (sel.stream().anyMatch(IProject.class::isInstance)) { IResourceDelta delta = event.getDelta(); if (delta != null) { IResourceDelta[] projDeltas = delta.getAffectedChildren(IResourceDelta.CHANGED); @@ -280,6 +280,13 @@ protected synchronized List getSelectedResources() { return super.getSelectedResources(); } + @Override + protected List getActionResources() { + // The close operation only ever applies to projects; drop any non-project + // elements of a mixed selection so execution does not fail with a cast. + return super.getActionResources().stream().filter(IProject.class::isInstance).toList(); + } + @Override protected synchronized List getSelectedNonResources() { return super.getSelectedNonResources(); diff --git a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseUnrelatedProjectsAction.java b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseUnrelatedProjectsAction.java index 2e0901417b5..de537b3d86d 100644 --- a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseUnrelatedProjectsAction.java +++ b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/CloseUnrelatedProjectsAction.java @@ -241,7 +241,8 @@ protected List getSelectedResources() { @Override public void resourceChanged(IResourceChangeEvent event) { // don't bother looking at delta if selection not applicable - if (selectionIsOfType(IResource.PROJECT)) { + List selectedResources = super.getSelectedResources(); + if (selectedResources.stream().anyMatch(IProject.class::isInstance)) { IResourceDelta delta = event.getDelta(); if (delta != null) { IResourceDelta[] projDeltas = delta.getAffectedChildren(IResourceDelta.CHANGED); diff --git a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/OpenResourceAction.java b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/OpenResourceAction.java index cbe3618076f..252a4cede64 100644 --- a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/OpenResourceAction.java +++ b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/OpenResourceAction.java @@ -139,7 +139,7 @@ protected String getProblemsTitle() { private boolean hasOtherClosedProjects() { //count the closed projects in the selection int closedInSelection = 0; - for (IResource project : getSelectedResources()) { + for (IResource project : getActionResources()) { if (!((IProject) project).isOpen()) { closedInSelection++; } @@ -154,6 +154,13 @@ protected void invokeOperation(IResource resource, IProgressMonitor monitor) thr ((IProject) resource).open(IResource.BACKGROUND_REFRESH, monitor); } + @Override + protected List getActionResources() { + // The open operation only ever applies to projects; drop any non-project + // elements of a mixed selection so execution does not fail with a cast. + return super.getActionResources().stream().filter(IProject.class::isInstance).toList(); + } + /** * Returns the preference for whether to open required projects when opening * a project. Consults the preference and prompts the user if necessary. @@ -190,7 +197,7 @@ public void resourceChanged(IResourceChangeEvent event) { // Warning: code duplicated in CloseResourceAction List sel = getSelectedResources(); // don't bother looking at delta if selection not applicable - if (selectionIsOfType(IResource.PROJECT)) { + if (sel.stream().anyMatch(IProject.class::isInstance)) { IResourceDelta delta = event.getDelta(); if (delta != null) { IResourceDelta[] projDeltas = delta.getAffectedChildren(IResourceDelta.CHANGED); @@ -304,13 +311,15 @@ protected boolean updateSelection(IStructuredSelection s) { // selected. setText(IDEWorkbenchMessages.OpenResourceAction_text); setToolTipText(IDEWorkbenchMessages.OpenResourceAction_toolTip); - if (!selectionIsOfType(IResource.PROJECT)) { + List projects = getSelectedResources().stream() + .filter(IProject.class::isInstance).map(IProject.class::cast).toList(); + if (projects.isEmpty()) { return false; } boolean hasClosedProjects = false; - for (IResource currentResource : getSelectedResources()) { - if (!((IProject) currentResource).isOpen()) { + for (IProject currentResource : projects) { + if (!currentResource.isOpen()) { if (hasClosedProjects) { setText(IDEWorkbenchMessages.OpenResourceAction_text_plural); setToolTipText(IDEWorkbenchMessages.OpenResourceAction_toolTip_plural); diff --git a/bundles/org.eclipse.ui.navigator.resources/plugin.xml b/bundles/org.eclipse.ui.navigator.resources/plugin.xml index 7eec1464661..d6007f86353 100644 --- a/bundles/org.eclipse.ui.navigator.resources/plugin.xml +++ b/bundles/org.eclipse.ui.navigator.resources/plugin.xml @@ -237,13 +237,8 @@ class="org.eclipse.ui.internal.navigator.resources.actions.ResourceMgmtActionProvider" id="org.eclipse.ui.navigator.resources.ResourceMgmtActions"> - - - - - - - + + diff --git a/tests/org.eclipse.ui.tests.navigator/src/org/eclipse/ui/tests/navigator/resources/ResourceMgmtActionProviderTests.java b/tests/org.eclipse.ui.tests.navigator/src/org/eclipse/ui/tests/navigator/resources/ResourceMgmtActionProviderTests.java index 128451a678b..cfc57fdc63d 100644 --- a/tests/org.eclipse.ui.tests.navigator/src/org/eclipse/ui/tests/navigator/resources/ResourceMgmtActionProviderTests.java +++ b/tests/org.eclipse.ui.tests.navigator/src/org/eclipse/ui/tests/navigator/resources/ResourceMgmtActionProviderTests.java @@ -10,10 +10,14 @@ *******************************************************************************/ package org.eclipse.ui.tests.navigator.resources; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import java.util.List; + import org.eclipse.core.resources.ICommand; +import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IProjectDescription; @@ -28,6 +32,8 @@ import org.eclipse.jface.action.MenuManager; import org.eclipse.jface.viewers.StructuredSelection; import org.eclipse.ui.actions.ActionContext; +import org.eclipse.ui.actions.CloseResourceAction; +import org.eclipse.ui.actions.OpenResourceAction; import org.eclipse.ui.internal.navigator.NavigatorContentService; import org.eclipse.ui.internal.navigator.extensions.CommonActionExtensionSite; import org.eclipse.ui.internal.navigator.resources.actions.ResourceMgmtActionProvider; @@ -118,6 +124,120 @@ public void testFillContextMenu_openProjectNoBuilderSelection() throws CoreExcep } } + /** + * Test for a file selected together with an open project: Close Project must + * be both present and enabled. Regression test for the bug where + * selectionIsOfType(PROJECT) disabled the action for any mixed selection. + * + * @throws CoreException + */ + @Test + public void testFillContextMenu_fileAndOpenProjectSelection_closeProjectEnabled() throws CoreException { + // _p1 is already open; _project has a known 'src' folder + files + IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test"); + openProj.open(null); + // Select a file alongside a project (the typical Ctrl+A expanded scenario) + ResourceMgmtActionProvider provider = providerForObjects(_p1, openProj.getFile(".project")); + provider.fillContextMenu(manager); + assertTrue(menuHasContribution("org.eclipse.ui.CloseResourceAction"), + "Close Project should be in the menu"); + assertTrue(isMenuContributionEnabled("org.eclipse.ui.CloseResourceAction"), + "Close Project should be enabled when open projects are in the selection"); + assertTrue(menuHasContribution("org.eclipse.ui.CloseUnrelatedProjectsAction"), + "Close Unrelated Projects should be in the menu"); + assertTrue(isMenuContributionEnabled("org.eclipse.ui.CloseUnrelatedProjectsAction"), + "Close Unrelated Projects should be enabled when open projects are in the selection"); + } + + /** + * Test for mixed selection: an open project alongside a non-adaptable element + * (e.g. a working set header from Ctrl+A in Project Explorer). Close Project + * and Refresh must still appear — regression test for issue #3790. + * + * @throws CoreException + */ + @Test + public void testFillContextMenu_mixedSelectionOpenProjectAndNonAdaptableElement() throws CoreException { + IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test"); + openProj.open(null); + // Plain Object does not implement IAdaptable, so it is never resolved to a + // project — it counts as a non-project element in the selection. + Object nonResource = new Object(); + ResourceMgmtActionProvider provider = providerForObjects(openProj, nonResource); + provider.fillContextMenu(manager); + checkMenuHasCorrectContributions(false, true, false, true, true); + } + + /** + * Test for a fully expanded selection: two open projects plus child resources + * from both (simulating Ctrl+A when both projects are expanded). Close Project + * must still appear for the open projects in the selection. + * + * @throws CoreException + */ + @Test + public void testFillContextMenu_twoOpenProjectsWithChildResourcesSelection() throws CoreException { + // _p1 and _p2 are already opened in setUp() + IFolder srcFolder = _project.getFolder("src"); + IFolder binFolder = _project.getFolder("bin"); + ResourceMgmtActionProvider provider = providerForObjects(_p1, _p2, srcFolder, binFolder); + provider.fillContextMenu(manager); + checkMenuHasCorrectContributions(false, true, false, true, true); + } + + /** + * Regression test for the ClassCastException that the always-on provider + * enablement could expose: when Close Project is invoked on a mixed selection + * (project plus a file plus a non-resource element, as produced by Ctrl+A), + * the action must reduce the selection to projects only. Otherwise run() casts + * every selected resource to IProject while building the scheduling rule. + * + * @throws CoreException + */ + @Test + public void testCloseResourceAction_actionResourcesContainProjectsOnly() throws CoreException { + IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test"); + openProj.open(null); + IFile projectFile = openProj.getFile(".project"); + StructuredSelection mixed = new StructuredSelection(new Object[] { openProj, projectFile, new Object() }); + + var action = new CloseResourceAction(() -> _commonNavigator.getViewSite().getShell()) { + List exposedActionResources() { + return getActionResources(); + } + }; + action.selectionChanged(mixed); + + assertEquals(List.of(openProj), action.exposedActionResources(), + "Close Project must operate on projects only, not files or non-resource elements"); + } + + /** + * Regression test for the ClassCastException in OpenResourceAction on a mixed + * selection: the action must reduce the selection to projects only, otherwise + * hasOtherClosedProjects() casts a non-project resource to IProject while + * opening projects with their references. + * + * @throws CoreException + */ + @Test + public void testOpenResourceAction_actionResourcesContainProjectsOnly() throws CoreException { + IProject openProj = ResourcesPlugin.getWorkspace().getRoot().getProject("Test"); + openProj.open(null); + IFile projectFile = openProj.getFile(".project"); + StructuredSelection mixed = new StructuredSelection(new Object[] { openProj, projectFile, new Object() }); + + var action = new OpenResourceAction(() -> _commonNavigator.getViewSite().getShell()) { + List exposedActionResources() { + return getActionResources(); + } + }; + action.selectionChanged(mixed); + + assertEquals(List.of(openProj), action.exposedActionResources(), + "Open Project must operate on projects only, not files or non-resource elements"); + } + /** * Test for 'open project' that doesn't have a builder attached - only 'open * project' should be disabled @@ -158,6 +278,19 @@ public void testFillContextMenu_openProjectWithBuilderSelection() throws CoreExc } } + /* + * Return a provider for a mixed/arbitrary selection (Object[]) + */ + private ResourceMgmtActionProvider providerForObjects(Object... selectedElements) { + ICommonActionExtensionSite cfg = new CommonActionExtensionSite("NA", "NA", + CommonViewerSiteFactory.createCommonViewerSite(_commonNavigator.getViewSite()), + (NavigatorContentService) _contentService, _viewer); + ResourceMgmtActionProvider provider = new ResourceMgmtActionProvider(); + provider.setContext(new ActionContext(new StructuredSelection(selectedElements))); + provider.init(cfg); + return provider; + } + /* * Return a provider, given the selected navigator items */ @@ -206,4 +339,16 @@ private boolean menuHasContribution(String contribution) { return false; } + /* + * Check whether the named menu entry is enabled + */ + private boolean isMenuContributionEnabled(String contribution) { + for (IContributionItem thisItem : manager.getItems()) { + if (thisItem.getId() != null && thisItem.getId().equals(contribution)) { + return thisItem.isEnabled(); + } + } + return false; + } + } From f67546f92e6ae44383cc17ed7204221825dd4eab Mon Sep 17 00:00:00 2001 From: Eclipse Platform Bot Date: Mon, 1 Jun 2026 09:49:06 +0000 Subject: [PATCH 2/2] Version bump(s) for 4.41 stream --- bundles/org.eclipse.ui.navigator.resources/META-INF/MANIFEST.MF | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.eclipse.ui.navigator.resources/META-INF/MANIFEST.MF b/bundles/org.eclipse.ui.navigator.resources/META-INF/MANIFEST.MF index a26be904ac0..eb4dacd798b 100644 --- a/bundles/org.eclipse.ui.navigator.resources/META-INF/MANIFEST.MF +++ b/bundles/org.eclipse.ui.navigator.resources/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: %Plugin.name Bundle-SymbolicName: org.eclipse.ui.navigator.resources; singleton:=true -Bundle-Version: 3.10.100.qualifier +Bundle-Version: 3.10.200.qualifier Bundle-Activator: org.eclipse.ui.internal.navigator.resources.plugin.WorkbenchNavigatorPlugin Bundle-Vendor: %Plugin.providerName Bundle-Localization: plugin