From 52eedcb4e4df0c5a1afdc9c04e2bb28f42e8982e Mon Sep 17 00:00:00 2001 From: Lars Vogel Date: Thu, 10 Sep 2026 12:51:05 +0200 Subject: [PATCH] Ask before closing or opening projects nested below the selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close Project and Open Project acted on exactly the selected projects, so a project that physically contains other projects, such as a Maven or Gradle root, left its modules open, and reopening the root brought back only the root. Open Project then asked about referenced projects from inside its job, a second dialog for the same action. Both actions now report the nested projects in a dialog with an "Include nested projects" check box, unchecked by default, and a "Remember my decision" toggle that stores the answer in two new workspace preferences, shown next to the setting for opening referenced projects. Open Project asks about referenced projects in that same dialog, with a second check box backed by the existing preference, and its job no longer prompts. Nothing is asked when neither applies. CloseUnrelatedProjectsAction opts out, it already confirms the projects it computes. The location matching and the dialog are shared by both actions. Assisted-by: multiple AI agents and layers of automated tooling 🤖 --- .../ui/actions/CloseResourceAction.java | 88 +++++- .../actions/CloseUnrelatedProjectsAction.java | 12 +- .../eclipse/ui/actions/NestedProjects.java | 69 +++++ .../ui/actions/OpenResourceAction.java | 179 +++++------ .../ui/actions/RelatedProjectsDialog.java | 120 ++++++++ .../internal/ide/IDEInternalPreferences.java | 10 + .../ide/IDEPreferenceInitializer.java | 2 + .../ui/internal/ide/IDEWorkbenchMessages.java | 18 +- .../dialogs/IDEWorkspacePreferencePage.java | 42 +++ .../ui/internal/ide/messages.properties | 18 +- .../internal/CloseResourceActionTest.java | 183 ++++++++++++ .../ui/tests/internal/InternalTestSuite.java | 2 + .../internal/OpenResourceActionTest.java | 280 ++++++++++++++++++ .../ui/tests/internal/ResourceActionTest.java | 76 +++++ 14 files changed, 1006 insertions(+), 93 deletions(-) create mode 100644 bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/NestedProjects.java create mode 100644 bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/RelatedProjectsDialog.java create mode 100644 tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/CloseResourceActionTest.java create mode 100644 tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/OpenResourceActionTest.java 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..594024646fe 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2017 IBM Corporation and others. + * Copyright (c) 2000, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -12,6 +12,7 @@ * IBM Corporation - initial API and implementation * Andrey Loskutov - Bug 41431, 462760, 461786 * Lucas Bullen (Red Hat Inc.) - Bug 522096 - "Close Projects" on working set + * Lars Vogel - ask before closing nested projects *******************************************************************************/ package org.eclipse.ui.actions; @@ -35,6 +36,7 @@ import org.eclipse.core.runtime.SafeRunner; import org.eclipse.core.runtime.jobs.ISchedulingRule; import org.eclipse.core.runtime.jobs.MultiRule; +import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.util.SafeRunnable; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.window.IShellProvider; @@ -48,7 +50,9 @@ import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.internal.ide.IDEInternalPreferences; import org.eclipse.ui.internal.ide.IDEWorkbenchMessages; +import org.eclipse.ui.internal.ide.IDEWorkbenchPlugin; import org.eclipse.ui.internal.ide.IIDEHelpContextIds; /** @@ -71,6 +75,9 @@ public class CloseResourceAction extends WorkspaceAction implements IResourceCha private String[] modelProviderIds; + /** Projects the last {@link #run()} decided to close, may be wider than the selection. */ + private List resourcesToClose; + /** * Creates a new action. * @@ -179,6 +186,7 @@ protected void invokeOperation(IResource resource, IProgressMonitor monitor) thr */ @Override public void run() { + resourcesToClose = null; // Get the items to close. List projects = getSelectedResources(); if (projects == null || projects.isEmpty()) { @@ -186,6 +194,13 @@ public void run() { return; } + projects = promptForProjectsToClose(projects); + if (projects == null) { + // the user cancelled the operation + return; + } + resourcesToClose = projects; + final IResource[] projectArray = projects.toArray(new IResource[projects.size()]); if (!IDE.saveAllEditors(projectArray, true)) { @@ -219,6 +234,77 @@ protected boolean shouldPerformResourcePruning() { return false; } + @Override + protected List getActionResources() { + if (resourcesToClose != null) { + return resourcesToClose; + } + return super.getActionResources(); + } + + /** + * Offers to close open projects nested below the selected ones, unless the + * preference already decides. + * + * @return the projects to close, or null if the user cancelled + */ + private List promptForProjectsToClose(List projects) { + if (!promptForRelatedProjects()) { + return projects; + } + List nestedProjects = NestedProjects.below(projects, true); + if (nestedProjects.isEmpty()) { + return projects; + } + IPreferenceStore store = IDEWorkbenchPlugin.getDefault().getPreferenceStore(); + String key = IDEInternalPreferences.CLOSE_NESTED_PROJECTS; + String value = store.getString(key); + if (IDEInternalPreferences.PSPM_NEVER.equals(value)) { + return projects; + } + if (!IDEInternalPreferences.PSPM_ALWAYS.equals(value)) { + RelatedProjectsDialog.Answer answer = RelatedProjectsDialog.open(getShell(), + IDEWorkbenchMessages.CloseResourceAction_promptTitle, nestedMessage(projects, nestedProjects), + IDEWorkbenchMessages.CloseResourceAction_close, store, key, null); + if (answer == null) { + return null; + } + if (!answer.includeNested()) { + return projects; + } + } + List allProjects = new ArrayList<>(projects); + allProjects.addAll(nestedProjects); + return allProjects; + } + + /** + * @return the question asked when the selection nests further open projects + */ + private static String nestedMessage(List projects, List nestedProjects) { + boolean oneProject = projects.size() == 1; + if (nestedProjects.size() == 1) { + return oneProject + ? NLS.bind(IDEWorkbenchMessages.CloseResourceAction_closeOneNestedBelowProject, + projects.get(0).getName()) + : IDEWorkbenchMessages.CloseResourceAction_closeOneNestedBelowSelection; + } + Integer count = Integer.valueOf(nestedProjects.size()); + return oneProject + ? NLS.bind(IDEWorkbenchMessages.CloseResourceAction_closeNestedBelowProject, count, + projects.get(0).getName()) + : NLS.bind(IDEWorkbenchMessages.CloseResourceAction_closeNestedBelowSelection, count); + } + + /** + * @return true to ask about projects implied by the selection but + * not part of it. Subclasses computing and confirming the projects to + * close themselves answer false. + */ + boolean promptForRelatedProjects() { + return true; + } + /** * The CloseResourceAction implementation of this * SelectionListenerAction method ensures that this action is 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 65f94ba9a21..5f6abf13f52 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2006, 2017 IBM Corporation and others. + * Copyright (c) 2006, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -14,6 +14,7 @@ * Andrey Loskutov - generified interface, bug 462760 * Mickael Istria (Red Hat Inc.) - Bug 486901 * Lucas Bullen (Red Hat Inc.) - Bug 522096 - "Close Projects" on working set + * Lars Vogel - ask before closing nested projects *******************************************************************************/ package org.eclipse.ui.actions; @@ -113,6 +114,15 @@ public void run() { } } + /** + * The projects to close come from the project graph, not from the selection, + * and {@link #promptForConfirmation()} already confirms them. + */ + @Override + boolean promptForRelatedProjects() { + return false; + } + /** * Rebuilds the project graph before the projects to close are computed from it. * Enablement may answer from a stale graph, but closing projects should not. A diff --git a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/NestedProjects.java b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/NestedProjects.java new file mode 100644 index 00000000000..b881dbd3387 --- /dev/null +++ b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/NestedProjects.java @@ -0,0 +1,69 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.actions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IPath; + +/** + * Finds projects located inside other projects, such as Maven or Gradle + * modules below their root. + */ +final class NestedProjects { + + private NestedProjects() { + } + + /** + * Returns the open or closed projects located inside the given ones but not + * among them, parents before their children. + */ + static List below(List projects, boolean open) { + List locations = new ArrayList<>(projects.size()); + for (IResource project : projects) { + IPath location = project.getLocation(); + if (location != null) { + locations.add(location); + } + } + if (locations.isEmpty()) { + return Collections.emptyList(); + } + List nestedProjects = new ArrayList<>(); + for (IProject project : ResourcesPlugin.getWorkspace().getRoot().getProjects()) { + if (project.isOpen() != open || projects.contains(project)) { + continue; + } + IPath location = project.getLocation(); + if (location == null) { + continue; + } + for (IPath selected : locations) { + if (!selected.equals(location) && selected.isPrefixOf(location)) { + nestedProjects.add(project); + break; + } + } + } + nestedProjects.sort(Comparator.comparingInt(project -> project.getLocation().segmentCount())); + return nestedProjects; + } +} 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 dd0a8693885..11f133d28ce 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 @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2000, 2015 IBM Corporation and others. + * Copyright (c) 2000, 2026 IBM Corporation and others. * * This program and the accompanying materials * are made available under the terms of the Eclipse Public License 2.0 @@ -12,6 +12,7 @@ * IBM Corporation - initial API and implementation * Mohamed Tarief , IBM - Bug 139211 * Lucas Bullen (Red Hat Inc.) - Bug 522096 - "Close Projects" on working set + * Lars Vogel - ask before opening nested projects *******************************************************************************/ package org.eclipse.ui.actions; @@ -19,28 +20,26 @@ import java.util.List; import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.IResourceChangeEvent; import org.eclipse.core.resources.IResourceChangeListener; import org.eclipse.core.resources.IResourceDelta; +import org.eclipse.core.resources.IWorkspace; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.resources.WorkspaceJob; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; -import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.SubMonitor; import org.eclipse.core.runtime.jobs.Job; -import org.eclipse.jface.dialogs.IDialogConstants; -import org.eclipse.jface.dialogs.MessageDialogWithToggle; import org.eclipse.jface.preference.IPreferenceStore; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.window.IShellProvider; -import org.eclipse.jface.window.Window; -import org.eclipse.swt.SWT; -import org.eclipse.swt.widgets.Display; +import org.eclipse.osgi.util.NLS; import org.eclipse.swt.widgets.Shell; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.internal.ide.IDEInternalPreferences; @@ -133,59 +132,11 @@ protected String getProblemsTitle() { return IDEWorkbenchMessages.OpenResourceAction_dialogTitle; } - /** - * Returns whether there are closed projects in the workspace that are - * not part of the current selection. - */ - private boolean hasOtherClosedProjects() { - //count the closed projects in the selection - int closedInSelection = 0; - for (IResource project : getSelectedResources()) { - if (!((IProject) project).isOpen()) { - closedInSelection++; - } - } - //there are other closed projects if the selection does - //not contain all closed projects in the workspace - return closedInSelection < countClosedProjects(); - } - @Override protected void invokeOperation(IResource resource, IProgressMonitor monitor) throws CoreException { ((IProject) resource).open(IResource.BACKGROUND_REFRESH, monitor); } - /** - * Returns the preference for whether to open required projects when opening - * a project. Consults the preference and prompts the user if necessary. - * - * @return true if referenced projects should be opened, and - * false otherwise. - */ - private boolean promptToOpenWithReferences() { - IPreferenceStore store = IDEWorkbenchPlugin.getDefault().getPreferenceStore(); - String key = IDEInternalPreferences.OPEN_REQUIRED_PROJECTS; - String value = store.getString(key); - if (MessageDialogWithToggle.ALWAYS.equals(value)) { - return true; - } - if (MessageDialogWithToggle.NEVER.equals(value)) { - return false; - } - String message = IDEWorkbenchMessages.OpenResourceAction_openRequiredProjects; - MessageDialogWithToggle dialog = MessageDialogWithToggle.openYesNoQuestion(getShell(), IDEWorkbenchMessages.Question, message, null, false, store, key); - int result = dialog.getReturnCode(); - // the result is equal to SWT.DEFAULT if the user uses the 'esc' key to close the dialog - if (result == Window.CANCEL || result == SWT.DEFAULT) { - throw new OperationCanceledException(); - } - return dialog.getReturnCode() == IDialogConstants.YES_ID; - } - - /** - * Handles a resource changed event by updating the enablement if one of the - * selected projects is opened or closed. - */ @Override public void resourceChanged(IResourceChangeEvent event) { // Warning: code duplicated in CloseResourceAction @@ -209,11 +160,68 @@ public void resourceChanged(IResourceChangeEvent event) { @Override public void run() { - try { - runOpenWithReferences(); - } catch (OperationCanceledException e) { - //just return when canceled + List projects = getActionResources(); + List nestedProjects = NestedProjects.below(projects, false); + IPreferenceStore store = IDEWorkbenchPlugin.getDefault().getPreferenceStore(); + String nestedValue = store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS); + String referencedValue = store.getString(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS); + boolean includeNested = IDEInternalPreferences.PSPM_ALWAYS.equals(nestedValue); + boolean includeReferenced = IDEInternalPreferences.PSPM_ALWAYS.equals(referencedValue); + boolean askNested = !nestedProjects.isEmpty() && prompts(nestedValue); + boolean askReferenced = prompts(referencedValue) && hasClosedReferences(projects, nestedProjects); + if (askNested || askReferenced) { + RelatedProjectsDialog.Answer answer = RelatedProjectsDialog.open(getShell(), + IDEWorkbenchMessages.OpenResourceAction_promptTitle, + askNested ? nestedMessage(projects, nestedProjects) + : IDEWorkbenchMessages.OpenResourceAction_referencedProjectsClosed, + IDEWorkbenchMessages.OpenResourceAction_open, store, + askNested ? IDEInternalPreferences.OPEN_NESTED_PROJECTS : null, + askReferenced ? IDEInternalPreferences.OPEN_REQUIRED_PROJECTS : null); + if (answer == null) { + return; + } + includeNested = askNested ? answer.includeNested() : includeNested; + includeReferenced = askReferenced ? answer.includeReferenced() : includeReferenced; + } + List allProjects = new ArrayList<>(projects); + if (includeNested) { + allProjects.addAll(nestedProjects); + } + runOpenWithReferences(allProjects, includeReferenced); + } + + private static boolean prompts(String preferenceValue) { + return !IDEInternalPreferences.PSPM_ALWAYS.equals(preferenceValue) + && !IDEInternalPreferences.PSPM_NEVER.equals(preferenceValue); + } + + /** + * Returns whether a project among the given ones references a closed + * project outside them. Closed projects cannot be asked, so the references + * come from their .project files. + */ + private static boolean hasClosedReferences(List projects, List nestedProjects) { + List candidates = new ArrayList<>(projects); + candidates.addAll(nestedProjects); + IWorkspace workspace = ResourcesPlugin.getWorkspace(); + for (IResource candidate : candidates) { + IPath location = candidate.getLocation(); + if (!(candidate instanceof IProject project) || project.isOpen() || location == null) { + continue; + } + IProjectDescription description; + try { + description = workspace.loadProjectDescription(location.append(IProjectDescription.DESCRIPTION_FILE_NAME)); + } catch (CoreException e) { + continue; + } + for (IProject reference : description.getReferencedProjects()) { + if (reference.exists() && !reference.isOpen() && !candidates.contains(reference)) { + return true; + } + } } + return false; } /** @@ -225,14 +233,30 @@ private static void logOpenFailure(IProject project, CoreException e) { } /** - * Opens the selected projects, and all related projects, in the background. + * @return the statement shown when the selection nests further closed projects */ - private void runOpenWithReferences() { - final List resources = new ArrayList<>(getActionResources()); + private static String nestedMessage(List projects, List nestedProjects) { + boolean oneProject = projects.size() == 1; + if (nestedProjects.size() == 1) { + return oneProject + ? NLS.bind(IDEWorkbenchMessages.OpenResourceAction_openOneNestedBelowProject, + projects.get(0).getName()) + : IDEWorkbenchMessages.OpenResourceAction_openOneNestedBelowSelection; + } + Integer count = Integer.valueOf(nestedProjects.size()); + return oneProject + ? NLS.bind(IDEWorkbenchMessages.OpenResourceAction_openNestedBelowProject, count, + projects.get(0).getName()) + : NLS.bind(IDEWorkbenchMessages.OpenResourceAction_openNestedBelowSelection, count); + } + + /** + * Opens the given projects in the background, with the projects they + * reference if wanted. + */ + private void runOpenWithReferences(List projects, boolean openProjectReferences) { + final List resources = new ArrayList<>(projects); Job job = new WorkspaceJob(removeMnemonics(getText())) { - private boolean openProjectReferences = true; - private boolean hasPrompted = false; - private boolean canceled = false; /** * Opens a project along with all projects it references */ @@ -247,31 +271,8 @@ private void doOpenWithReferences(IProject project, IProgressMonitor mon) throws logOpenFailure(project, e); return; } - final IProject[] references = project.getReferencedProjects(); - if (!hasPrompted) { - openProjectReferences = false; - for (IProject reference : references) { - if (reference.exists() && !reference.isOpen()) { - openProjectReferences = true; - break; - } - } - if (openProjectReferences && hasOtherClosedProjects()) { - Display.getDefault().syncExec(() -> { - try { - openProjectReferences = promptToOpenWithReferences(); - } catch (OperationCanceledException e) { - canceled = true; - } - //remember that we have prompted to avoid repeating the analysis - hasPrompted = true; - }); - if (canceled) { - throw new OperationCanceledException(); - } - } - } if (openProjectReferences) { + IProject[] references = project.getReferencedProjects(); SubMonitor loopMonitor = subMonitor.split(1).setWorkRemaining(references.length); for (IProject reference : references) { doOpenWithReferences(reference, loopMonitor.split(1)); diff --git a/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/RelatedProjectsDialog.java b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/RelatedProjectsDialog.java new file mode 100644 index 00000000000..7cd2af547de --- /dev/null +++ b/bundles/org.eclipse.ui.ide/extensions/org/eclipse/ui/actions/RelatedProjectsDialog.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.actions; + +import java.util.LinkedHashMap; + +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.dialogs.MessageDialogWithToggle; +import org.eclipse.jface.layout.GridDataFactory; +import org.eclipse.jface.layout.GridLayoutFactory; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.internal.ide.IDEWorkbenchMessages; + +/** + * Asks whether to include the projects nested below or referenced by the + * selection, with a toggle that stores the answers in preferences. + */ +final class RelatedProjectsDialog extends MessageDialogWithToggle { + + /** The answer, each flag only meaningful when its check box was shown. */ + record Answer(boolean includeNested, boolean includeReferenced) { + } + + private final IPreferenceStore store; + private final String nestedKey; + private final String referencedKey; + private Button nestedButton; + private Button referencedButton; + private Answer answer; + + private RelatedProjectsDialog(Shell parent, String title, String message, String actionLabel, + IPreferenceStore store, String nestedKey, String referencedKey) { + super(parent, title, null, message, QUESTION, buttons(actionLabel), 0, null, false); + setShellStyle(getShellStyle() | SWT.SHEET); + this.store = store; + this.nestedKey = nestedKey; + this.referencedKey = referencedKey; + } + + /** The map fixes the ids, a plain label array gets internal ids instead of OK_ID. */ + private static LinkedHashMap buttons(String actionLabel) { + LinkedHashMap buttons = new LinkedHashMap<>(); + buttons.put(actionLabel, Integer.valueOf(IDialogConstants.OK_ID)); + buttons.put(IDialogConstants.CANCEL_LABEL, Integer.valueOf(IDialogConstants.CANCEL_ID)); + return buttons; + } + + /** + * Opens the dialog with a check box for each non-null preference key and + * returns the answer, or null if the user cancelled. A checked + * toggle stores each answer under its key as {@link #ALWAYS} or + * {@link #NEVER}. + */ + static Answer open(Shell parent, String title, String message, String actionLabel, IPreferenceStore store, + String nestedKey, String referencedKey) { + RelatedProjectsDialog dialog = new RelatedProjectsDialog(parent, title, message, actionLabel, store, + nestedKey, referencedKey); + if (dialog.open() != IDialogConstants.OK_ID) { + return null; + } + return dialog.answer; + } + + @Override + protected Control createCustomArea(Composite parent) { + Composite area = new Composite(parent, SWT.NONE); + GridLayoutFactory.fillDefaults().applyTo(area); + GridDataFactory.fillDefaults().span(2, 1).applyTo(area); + if (nestedKey != null) { + nestedButton = createCheckBox(area, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested); + } + if (referencedKey != null) { + referencedButton = createCheckBox(area, IDEWorkbenchMessages.RelatedProjectsDialog_includeReferenced); + } + return area; + } + + private static Button createCheckBox(Composite parent, String text) { + Button button = new Button(parent, SWT.CHECK | SWT.LEFT); + button.setText(text); + button.setFont(parent.getFont()); + return button; + } + + @Override + protected void buttonPressed(int buttonId) { + answer = new Answer(isChecked(nestedButton), isChecked(referencedButton)); + if (buttonId == IDialogConstants.OK_ID && getToggleState()) { + remember(nestedKey, answer.includeNested()); + remember(referencedKey, answer.includeReferenced()); + } + super.buttonPressed(buttonId); + } + + private static boolean isChecked(Button button) { + return button != null && button.getSelection(); + } + + private void remember(String key, boolean include) { + if (key != null) { + store.setValue(key, include ? ALWAYS : NEVER); + } + } +} diff --git a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEInternalPreferences.java b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEInternalPreferences.java index 1e6e940802c..d2f33bc2f1f 100644 --- a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEInternalPreferences.java +++ b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEInternalPreferences.java @@ -55,6 +55,16 @@ public interface IDEInternalPreferences { */ String CLOSE_UNRELATED_PROJECTS = "CLOSE_UNRELATED_PROJECTS"; //$NON-NLS-1$ + /** + * (String) Whether to open projects nested below a project that is opened. + */ + String OPEN_NESTED_PROJECTS = "OPEN_NESTED_PROJECTS"; //$NON-NLS-1$ + + /** + * (String) Whether to close projects nested below a project that is closed. + */ + String CLOSE_NESTED_PROJECTS = "CLOSE_NESTED_PROJECTS"; //$NON-NLS-1$ + String PSPM_PROMPT = MessageDialogWithToggle.PROMPT; String PSPM_ALWAYS = MessageDialogWithToggle.ALWAYS; diff --git a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEPreferenceInitializer.java b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEPreferenceInitializer.java index 7978720f57e..201ae2f3067 100644 --- a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEPreferenceInitializer.java +++ b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEPreferenceInitializer.java @@ -58,6 +58,8 @@ public void initializeDefaultPreferences() { node.put(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); node.putBoolean(IDEInternalPreferences.CLOSE_UNRELATED_PROJECTS, false); + node.put(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + node.put(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); node.putBoolean(IDEInternalPreferences.WARN_ABOUT_WORKSPACE_INCOMPATIBILITY, true); diff --git a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEWorkbenchMessages.java b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEWorkbenchMessages.java index a5132dcef7d..28af01b5961 100644 --- a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEWorkbenchMessages.java +++ b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/IDEWorkbenchMessages.java @@ -322,7 +322,13 @@ public class IDEWorkbenchMessages extends NLS { public static String OpenResourceAction_problemMessage; public static String OpenResourceAction_operationMessage; public static String OpenResourceAction_operationMessage_plural; - public static String OpenResourceAction_openRequiredProjects; + public static String OpenResourceAction_promptTitle; + public static String OpenResourceAction_referencedProjectsClosed; + public static String OpenResourceAction_openOneNestedBelowProject; + public static String OpenResourceAction_openNestedBelowProject; + public static String OpenResourceAction_openOneNestedBelowSelection; + public static String OpenResourceAction_openNestedBelowSelection; + public static String OpenResourceAction_open; public static String CloseResourceAction_text; public static String CloseResourceAction_text_plural; @@ -335,6 +341,14 @@ public class IDEWorkbenchMessages extends NLS { public static String CloseResourceAction_problemMessage; public static String CloseResourceAction_operationMessage; public static String CloseResourceAction_operationMessage_plural; + public static String CloseResourceAction_promptTitle; + public static String CloseResourceAction_closeOneNestedBelowProject; + public static String CloseResourceAction_closeNestedBelowProject; + public static String CloseResourceAction_closeOneNestedBelowSelection; + public static String CloseResourceAction_closeNestedBelowSelection; + public static String CloseResourceAction_close; + public static String RelatedProjectsDialog_includeNested; + public static String RelatedProjectsDialog_includeReferenced; public static String CloseUnrelatedProjectsAction_text; public static String CloseUnrelatedProjectsAction_text_plural; @@ -562,6 +576,8 @@ public class IDEWorkbenchMessages extends NLS { public static String IDEWorkspacePreference_otherLineDelim; public static String IDEWorkspacePreference_relatedLink; public static String IDEWorkspacePreference_openReferencedProjects; + public static String IDEWorkspacePreference_openNestedProjects; + public static String IDEWorkspacePreference_closeNestedProjects; public static String IDEWorkspacePreference_closeUnrelatedProjectsToolTip; public static String IDEWorkspacePreference_windowTitleGroupText; public static String IDEWorkspacePreference_showLocationInWindowTitle; diff --git a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/dialogs/IDEWorkspacePreferencePage.java b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/dialogs/IDEWorkspacePreferencePage.java index c37d677330d..4cdd557ac1d 100644 --- a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/dialogs/IDEWorkspacePreferencePage.java +++ b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/dialogs/IDEWorkspacePreferencePage.java @@ -110,6 +110,10 @@ public class IDEWorkspacePreferencePage extends PreferencePage implements IWorkb private ComboFieldEditor openReferencesEditor; + private ComboFieldEditor openNestedEditor; + + private ComboFieldEditor closeNestedEditor; + private StringFieldEditor systemExplorer; private ComboFieldEditor missingNatureSeverityCombo; @@ -144,6 +148,8 @@ protected Control createContents(Composite parent) { Composite comboParent = new Composite(composite, SWT.NONE); comboParent.setLayout(new GridLayout(2, false)); createOpenPrefControls(comboParent); + createOpenNestedPrefControls(comboParent); + createCloseNestedPrefControls(comboParent); createMissingNaturePref(comboParent); createMissingEncodingPref(comboParent); @@ -236,6 +242,38 @@ private void createOpenPrefControls(Composite parent) { openReferencesEditor.load(); } + /** + * Creates controls for the preference to open nested projects. + */ + private void createOpenNestedPrefControls(Composite parent) { + String name = IDEInternalPreferences.OPEN_NESTED_PROJECTS; + String label = IDEWorkbenchMessages.IDEWorkspacePreference_openNestedProjects; + String[][] namesAndValues = { + { Action.removeMnemonics(IDEWorkbenchMessages.Always), IDEInternalPreferences.PSPM_ALWAYS }, + { Action.removeMnemonics(IDEWorkbenchMessages.Never), IDEInternalPreferences.PSPM_NEVER }, + { Action.removeMnemonics(IDEWorkbenchMessages.Prompt), IDEInternalPreferences.PSPM_PROMPT } }; + openNestedEditor = new ComboFieldEditorInGrid(name, label, namesAndValues, parent); + openNestedEditor.setPreferenceStore(getIDEPreferenceStore()); + openNestedEditor.setPage(this); + openNestedEditor.load(); + } + + /** + * Creates controls for the preference to close nested projects. + */ + private void createCloseNestedPrefControls(Composite parent) { + String name = IDEInternalPreferences.CLOSE_NESTED_PROJECTS; + String label = IDEWorkbenchMessages.IDEWorkspacePreference_closeNestedProjects; + String[][] namesAndValues = { + { Action.removeMnemonics(IDEWorkbenchMessages.Always), IDEInternalPreferences.PSPM_ALWAYS }, + { Action.removeMnemonics(IDEWorkbenchMessages.Never), IDEInternalPreferences.PSPM_NEVER }, + { Action.removeMnemonics(IDEWorkbenchMessages.Prompt), IDEInternalPreferences.PSPM_PROMPT } }; + closeNestedEditor = new ComboFieldEditorInGrid(name, label, namesAndValues, parent); + closeNestedEditor.setPreferenceStore(getIDEPreferenceStore()); + closeNestedEditor.setPage(this); + closeNestedEditor.load(); + } + /** * Creates controls for the preference to close unrelated projects. * @param parent The parent control @@ -562,6 +600,8 @@ protected void performDefaults() { encodingEditor.loadDefault(); lineSeparatorEditor.loadDefault(); openReferencesEditor.loadDefault(); + openNestedEditor.loadDefault(); + closeNestedEditor.loadDefault(); missingNatureSeverityCombo.loadDefault(); missingEncodingSeverityCombo.loadDefault(); @@ -628,6 +668,8 @@ public boolean performOk() { encodingEditor.store(); lineSeparatorEditor.store(); openReferencesEditor.store(); + openNestedEditor.store(); + closeNestedEditor.store(); missingNatureSeverityCombo.store(); missingEncodingSeverityCombo.store(); diff --git a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/messages.properties b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/messages.properties index f267fd94efb..85ba3216a06 100644 --- a/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/messages.properties +++ b/bundles/org.eclipse.ui.ide/src/org/eclipse/ui/internal/ide/messages.properties @@ -281,7 +281,13 @@ OpenResourceAction_dialogTitle = Open Problems OpenResourceAction_problemMessage = Problems occurred opening the selected resources. OpenResourceAction_operationMessage = Opening project... OpenResourceAction_operationMessage_plural = Opening projects... -OpenResourceAction_openRequiredProjects = Should referenced projects also be opened where applicable? +OpenResourceAction_promptTitle = Open Project +OpenResourceAction_referencedProjectsClosed = The selected projects reference closed projects. +OpenResourceAction_openOneNestedBelowProject = ''{0}'' contains 1 closed nested project. +OpenResourceAction_openNestedBelowProject = ''{1}'' contains {0} closed nested projects. +OpenResourceAction_openOneNestedBelowSelection = The selected projects contain 1 closed nested project. +OpenResourceAction_openNestedBelowSelection = The selected projects contain {0} closed nested projects. +OpenResourceAction_open = &Open CloseResourceAction_text = Clo&se Project CloseResourceAction_text_plural = Clo&se Projects @@ -294,6 +300,14 @@ CloseResourceAction_title = Close Problems CloseResourceAction_problemMessage = Problems occurred closing the selected resources. CloseResourceAction_operationMessage = Closing project... CloseResourceAction_operationMessage_plural = Closing projects... +CloseResourceAction_promptTitle = Close Project +CloseResourceAction_closeOneNestedBelowProject = ''{0}'' contains 1 open nested project. +CloseResourceAction_closeNestedBelowProject = ''{1}'' contains {0} open nested projects. +CloseResourceAction_closeOneNestedBelowSelection = The selected projects contain 1 open nested project. +CloseResourceAction_closeNestedBelowSelection = The selected projects contain {0} open nested projects. +CloseResourceAction_close = &Close +RelatedProjectsDialog_includeNested = &Include nested projects +RelatedProjectsDialog_includeReferenced = Include &referenced projects CloseUnrelatedProjectsAction_text = Close &Unrelated Project CloseUnrelatedProjectsAction_text_plural = Close &Unrelated Projects @@ -539,6 +553,8 @@ IDEWorkspacePreference_defaultLineDelimProj=Inh&erited from container ({0}) IDEWorkspacePreference_otherLineDelim= Ot&her: IDEWorkspacePreference_relatedLink = See {0} for workspace startup and shutdown preferences. IDEWorkspacePreference_openReferencedProjects = Open referenced projects when a project is opened: +IDEWorkspacePreference_openNestedProjects = Open nested projects when a project is opened: +IDEWorkspacePreference_closeNestedProjects = Close nested projects when a project is closed: IDEWorkspacePreference_closeUnrelatedProjectsToolTip = Close unrelated projects without prompt IDEWorkspacePreference_windowTitleGroupText=Window title IDEWorkspacePreference_showLocationInWindowTitle=Show &full workspace path: diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/CloseResourceActionTest.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/CloseResourceActionTest.java new file mode 100644 index 00000000000..0dfd33dadee --- /dev/null +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/CloseResourceActionTest.java @@ -0,0 +1,183 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.tests.internal; + +import static org.eclipse.ui.tests.harness.util.UITestUtil.waitForJobs; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.jface.viewers.StructuredSelection; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.actions.CloseResourceAction; +import org.eclipse.ui.internal.ide.IDEInternalPreferences; +import org.eclipse.ui.internal.ide.IDEWorkbenchMessages; +import org.eclipse.ui.internal.ide.IDEWorkbenchPlugin; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Closing a project whose location contains further open projects. + */ +public class CloseResourceActionTest extends ResourceActionTest { + + private IProject parent; + private IProject nested; + private IPreferenceStore store; + private String oldPreference; + + @Before + public void createProjects() throws CoreException { + IWorkspace workspace = ResourcesPlugin.getWorkspace(); + parent = workspace.getRoot().getProject("CloseResourceActionTest_parent"); + parent.create(null); + parent.open(null); + nested = workspace.getRoot().getProject("CloseResourceActionTest_nested"); + IProjectDescription description = workspace.newProjectDescription(nested.getName()); + description.setLocation(parent.getLocation().append(nested.getName())); + nested.create(description, null); + nested.open(null); + store = IDEWorkbenchPlugin.getDefault().getPreferenceStore(); + oldPreference = store.getString(IDEInternalPreferences.CLOSE_NESTED_PROJECTS); + } + + @After + public void deleteProjects() throws CoreException { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, oldPreference); + nested.delete(true, null); + parent.delete(true, null); + waitForJobs(0, 30_000); + } + + @Test + public void testClosesOnlySelectedProjectByPreference() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + close(parent); + assertFalse(parent.isOpen()); + assertTrue(nested.isOpen()); + } + + @Test + public void testClosesNestedProjectsByPreference() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_ALWAYS); + close(parent); + assertFalse(parent.isOpen()); + assertFalse(nested.isOpen()); + } + + @Test + public void testDoesNotPromptWhenNestedProjectIsSelected() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL); + close(parent, nested); + assertFalse(answered[0]); + assertFalse(parent.isOpen()); + assertFalse(nested.isOpen()); + } + + @Test + public void testDoesNotPromptWithoutNestedProjects() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL); + close(nested); + assertFalse(answered[0]); + assertTrue(parent.isOpen()); + assertFalse(nested.isOpen()); + } + + @Test + public void testClosesOnlySelectedProjectWhenNotIncluded() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDEWorkbenchMessages.CloseResourceAction_close); + close(parent); + assertTrue(answered[0]); + assertFalse(parent.isOpen()); + assertTrue(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.CLOSE_NESTED_PROJECTS)); + } + + @Test + public void testClosesNestedProjectsWhenIncluded() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDEWorkbenchMessages.CloseResourceAction_close, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested); + close(parent); + assertTrue(answered[0]); + assertFalse(parent.isOpen()); + assertFalse(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.CLOSE_NESTED_PROJECTS)); + } + + @Test + public void testRemembersToIncludeNestedProjects() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDEWorkbenchMessages.CloseResourceAction_close, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + close(parent); + assertTrue(answered[0]); + assertFalse(parent.isOpen()); + assertFalse(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_ALWAYS, store.getString(IDEInternalPreferences.CLOSE_NESTED_PROJECTS)); + } + + @Test + public void testRemembersToSkipNestedProjects() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDEWorkbenchMessages.CloseResourceAction_close, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + close(parent); + assertTrue(answered[0]); + assertFalse(parent.isOpen()); + assertTrue(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_NEVER, store.getString(IDEInternalPreferences.CLOSE_NESTED_PROJECTS)); + } + + @Test + public void testCancelChangesNothing() { + store.setValue(IDEInternalPreferences.CLOSE_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.CloseResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + close(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertTrue(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.CLOSE_NESTED_PROJECTS)); + } + + private static void close(IProject... projects) { + Shell activeShell = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(); + CloseResourceAction action = new CloseResourceAction(() -> activeShell); + action.selectionChanged(new StructuredSelection(projects)); + assertTrue(action.isEnabled()); + action.run(); + processUIEvents(); + waitForJobs(0, 30_000); + } +} diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/InternalTestSuite.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/InternalTestSuite.java index ff24811f308..1cf77ef6180 100644 --- a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/InternalTestSuite.java +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/InternalTestSuite.java @@ -67,6 +67,8 @@ SaveablesListTest.class, Bug540297WorkbenchPageFindViewTest.class, Bug549139Test.class, + CloseResourceActionTest.class, + OpenResourceActionTest.class, LargeFileLimitsPreferenceHandlerTest.class, WorkbookEditorsHandlerTest.class, ScopeAreaTest.class, diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/OpenResourceActionTest.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/OpenResourceActionTest.java new file mode 100644 index 00000000000..bd999ee49d0 --- /dev/null +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/OpenResourceActionTest.java @@ -0,0 +1,280 @@ +/******************************************************************************* + * Copyright (c) 2026 Lars Vogel and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Lars Vogel - initial API and implementation + *******************************************************************************/ +package org.eclipse.ui.tests.internal; + +import static org.eclipse.ui.tests.harness.util.UITestUtil.waitForJobs; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.jface.viewers.StructuredSelection; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.actions.OpenResourceAction; +import org.eclipse.ui.internal.ide.IDEInternalPreferences; +import org.eclipse.ui.internal.ide.IDEWorkbenchMessages; +import org.eclipse.ui.internal.ide.IDEWorkbenchPlugin; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Opening a project whose location contains further closed projects. + */ +public class OpenResourceActionTest extends ResourceActionTest { + + private IProject parent; + private IProject nested; + private IProject referenced; + private IPreferenceStore store; + private String oldPreference; + private String oldReferencedPreference; + + @Before + public void createProjects() throws CoreException { + IWorkspace workspace = ResourcesPlugin.getWorkspace(); + parent = workspace.getRoot().getProject("OpenResourceActionTest_parent"); + parent.create(null); + parent.open(null); + nested = workspace.getRoot().getProject("OpenResourceActionTest_nested"); + IProjectDescription description = workspace.newProjectDescription(nested.getName()); + description.setLocation(parent.getLocation().append(nested.getName())); + nested.create(description, null); + nested.open(null); + nested.close(null); + referenced = workspace.getRoot().getProject("OpenResourceActionTest_referenced"); + referenced.create(null); + referenced.open(null); + referenced.close(null); + IProjectDescription parentDescription = parent.getDescription(); + parentDescription.setReferencedProjects(new IProject[] { referenced }); + parent.setDescription(parentDescription, null); + parent.close(null); + store = IDEWorkbenchPlugin.getDefault().getPreferenceStore(); + oldPreference = store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS); + oldReferencedPreference = store.getString(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS); + // the nested tests ask only about nested projects + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + } + + @After + public void deleteProjects() throws CoreException { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, oldPreference); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, oldReferencedPreference); + nested.delete(true, null); + referenced.delete(true, null); + parent.delete(true, null); + waitForJobs(0, 30_000); + } + + @Test + public void testOpensOnlySelectedProjectByPreference() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + open(parent); + assertTrue(parent.isOpen()); + assertFalse(nested.isOpen()); + } + + @Test + public void testOpensNestedProjectsByPreference() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_ALWAYS); + open(parent); + assertTrue(parent.isOpen()); + assertTrue(nested.isOpen()); + } + + @Test + public void testDoesNotPromptWhenNestedProjectIsSelected() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL); + open(parent, nested); + assertFalse(answered[0]); + assertTrue(parent.isOpen()); + assertTrue(nested.isOpen()); + } + + @Test + public void testDoesNotPromptWithoutNestedProjects() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL); + open(nested); + assertFalse(answered[0]); + assertFalse(parent.isOpen()); + assertTrue(nested.isOpen()); + } + + @Test + public void testOpensOnlySelectedProjectWhenNotIncluded() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertFalse(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + } + + @Test + public void testOpensNestedProjectsWhenIncluded() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertTrue(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + } + + @Test + public void testRemembersToIncludeNestedProjects() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertTrue(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_ALWAYS, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + } + + @Test + public void testRemembersToSkipNestedProjects() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertFalse(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_NEVER, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + } + + @Test + public void testCancelChangesNothing() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + open(parent); + assertTrue(answered[0]); + assertFalse(parent.isOpen()); + assertFalse(nested.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + } + + @Test + public void testOpensReferencedProjectsByPreference() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_ALWAYS); + open(parent); + assertTrue(parent.isOpen()); + assertTrue(referenced.isOpen()); + assertFalse(nested.isOpen()); + } + + @Test + public void testOpensReferencedProjectsWhenIncluded() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open, IDEWorkbenchMessages.RelatedProjectsDialog_includeReferenced); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertTrue(referenced.isOpen()); + assertFalse(nested.isOpen()); + } + + @Test + public void testSkipsReferencedProjectsWhenNotIncluded() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertFalse(referenced.isOpen()); + } + + @Test + public void testAsksBothQuestionsAtOnce() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDEWorkbenchMessages.OpenResourceAction_open, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested, + IDEWorkbenchMessages.RelatedProjectsDialog_includeReferenced, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + open(parent); + assertTrue(answered[0]); + assertTrue(parent.isOpen()); + assertTrue(nested.isOpen()); + assertTrue(referenced.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_ALWAYS, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + assertEquals(IDEInternalPreferences.PSPM_ALWAYS, store.getString(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS)); + } + + @Test + public void testCancelWithBothQuestionsChangesNothing() { + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL, IDEWorkbenchMessages.RelatedProjectsDialog_includeNested, + IDEWorkbenchMessages.RelatedProjectsDialog_includeReferenced, + JFaceResources.getString("MessageDialogWithToggle.defaultToggleMessage")); + open(parent); + assertTrue(answered[0]); + assertFalse(parent.isOpen()); + assertFalse(nested.isOpen()); + assertFalse(referenced.isOpen()); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.OPEN_NESTED_PROJECTS)); + assertEquals(IDEInternalPreferences.PSPM_PROMPT, store.getString(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS)); + } + + @Test + public void testDoesNotAskWhenReferencedProjectIsOpen() throws CoreException { + referenced.open(null); + store.setValue(IDEInternalPreferences.OPEN_NESTED_PROJECTS, IDEInternalPreferences.PSPM_NEVER); + store.setValue(IDEInternalPreferences.OPEN_REQUIRED_PROJECTS, IDEInternalPreferences.PSPM_PROMPT); + boolean[] answered = answerDialog(IDEWorkbenchMessages.OpenResourceAction_promptTitle, + IDialogConstants.CANCEL_LABEL); + open(parent); + assertFalse(answered[0]); + assertTrue(parent.isOpen()); + } + + private static void open(IProject... projects) { + Shell activeShell = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getShell(); + OpenResourceAction action = new OpenResourceAction(() -> activeShell); + action.selectionChanged(new StructuredSelection(projects)); + assertTrue(action.isEnabled()); + action.run(); + processUIEvents(); + waitForJobs(0, 30_000); + } +} diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/ResourceActionTest.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/ResourceActionTest.java index 33145085d82..c35494dcca6 100644 --- a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/ResourceActionTest.java +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/internal/ResourceActionTest.java @@ -1,8 +1,18 @@ package org.eclipse.ui.tests.internal; +import static org.junit.Assert.assertNotNull; + +import java.util.ArrayList; +import java.util.List; + import org.eclipse.core.runtime.jobs.Job; import org.eclipse.jface.window.IShellProvider; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; import org.eclipse.ui.actions.DeleteResourceAction; import org.eclipse.ui.internal.ide.IDEWorkbenchMessages; import org.eclipse.ui.internal.operations.AdvancedValidationUserApprover; @@ -22,6 +32,7 @@ public void setUp() throws Exception { @After public void tearDown() throws Exception { AdvancedValidationUserApprover.AUTOMATED_MODE = false; + pollers.clear(); } protected static void joinDeleteResourceActionJobs() { @@ -46,6 +57,71 @@ private static void joinJob(String deleteActionJobName) { } } + private static final List pollers = new ArrayList<>(); + + /** + * Answers the next modal dialog with the given title from the event loop it + * runs: sets the check boxes with the given labels and presses the button + * with the given label. The returned flag reports whether the dialog was + * seen. Polling stops at the end of the test. + */ + protected static boolean[] answerDialog(String title, String button, String... checkBoxes) { + boolean[] answered = { false }; + Display display = Display.getCurrent(); + Runnable poller = new Runnable() { + @Override + public void run() { + if (!pollers.contains(this)) { + return; + } + Shell shell = findShell(display, title); + if (shell == null) { + display.timerExec(50, this); + return; + } + pollers.remove(this); + for (String checkBox : checkBoxes) { + Button check = findButton(shell, checkBox); + assertNotNull(checkBox, check); + check.setSelection(true); + check.notifyListeners(SWT.Selection, null); + } + Button push = findButton(shell, button); + assertNotNull(button, push); + answered[0] = true; + push.notifyListeners(SWT.Selection, null); + } + }; + pollers.add(poller); + display.timerExec(50, poller); + return answered; + } + + private static Shell findShell(Display display, String title) { + for (Shell shell : display.getShells()) { + if (title.equals(shell.getText()) && shell.isVisible()) { + return shell; + } + } + return null; + } + + private static Button findButton(Composite parent, String label) { + String plain = label.replace("&", ""); + for (Control child : parent.getChildren()) { + if (child instanceof Button button && plain.equals(button.getText().replace("&", ""))) { + return button; + } + if (child instanceof Composite composite) { + Button found = findButton(composite, label); + if (found != null) { + return found; + } + } + } + return null; + } + /** * After an internal action, see if there are any outstanding SWT events. */