You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@netbeans.apache.org by sd...@apache.org on 2022/03/08 18:54:57 UTC
[netbeans] branch master updated: Alternative ProjectProblems presenter for LSP server.
This is an automated email from the ASF dual-hosted git repository.
sdedic pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/netbeans.git
The following commit(s) were added to refs/heads/master by this push:
new 540fb3e Alternative ProjectProblems presenter for LSP server.
new d9bd08e Merge pull request #3568 from sdedic/projects/broken-references
540fb3e is described below
commit 540fb3ea373cd79bd92036bb4cd2560486b398a7
Author: Svata Dedic <sv...@oracle.com>
AuthorDate: Sat Jan 15 16:48:26 2022 +0100
Alternative ProjectProblems presenter for LSP server.
---
.../project/ui/problems/BrokenReferencesImpl.java | 212 ++--
ide/projectuiapi/apichanges.xml | 15 +
ide/projectuiapi/nbproject/project.properties | 2 +-
.../netbeans/api/project/ui/ProjectProblems.java | 25 +-
.../project/ui/ProjectProblemsImplementation.java | 49 +
.../nbcode/integration/nbproject/project.xml | 11 +-
.../integration/LspBrokenReferencesImpl.java | 35 +
java/java.lsp.server/nbproject/project.properties | 2 +-
java/java.lsp.server/nbproject/project.xml | 12 +
.../lsp/server/project/BrokenReferencesImpl.java | 160 +++
.../lsp/server/project/BrokenReferencesModel.java | 318 +++++
.../lsp/server/project/ProjectAlertPresenter.java | 808 ++++++++++++
.../lsp/server/ui/AbstractLspBrokenReferences.java | 48 +
.../project/LspBrokenReferencesImplTest.java | 1287 ++++++++++++++++++++
14 files changed, 2894 insertions(+), 90 deletions(-)
diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/problems/BrokenReferencesImpl.java b/ide/projectui/src/org/netbeans/modules/project/ui/problems/BrokenReferencesImpl.java
index 195ed02..263ef563 100644
--- a/ide/projectui/src/org/netbeans/modules/project/ui/problems/BrokenReferencesImpl.java
+++ b/ide/projectui/src/org/netbeans/modules/project/ui/problems/BrokenReferencesImpl.java
@@ -22,13 +22,14 @@ import java.awt.Dialog;
import java.awt.GraphicsEnvironment;
import java.util.Collection;
import java.util.HashSet;
+import java.util.concurrent.CompletableFuture;
import javax.swing.JButton;
+import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import org.netbeans.api.annotations.common.NonNull;
import org.netbeans.api.project.Project;
import org.netbeans.api.project.ProjectUtils;
-import org.netbeans.modules.project.uiapi.BrokenReferencesImplementation;
import org.openide.DialogDescriptor;
import org.openide.DialogDisplayer;
import org.openide.util.NbBundle;
@@ -36,6 +37,7 @@ import org.openide.util.RequestProcessor;
import org.openide.util.lookup.ServiceProvider;
import org.openide.windows.WindowManager;
import static org.netbeans.modules.project.ui.problems.Bundle.*;
+import org.netbeans.spi.project.ui.ProjectProblemsImplementation;
import org.openide.awt.StatusDisplayer;
import org.openide.util.Parameters;
@@ -43,15 +45,19 @@ import org.openide.util.Parameters;
*
* @author Tomas Zezula
*/
-@ServiceProvider(service=BrokenReferencesImplementation.class)
-public class BrokenReferencesImpl implements BrokenReferencesImplementation {
+@ServiceProvider(service=ProjectProblemsImplementation.class)
+public class BrokenReferencesImpl implements ProjectProblemsImplementation {
private static final boolean suppressBrokenRefAlert = Boolean.getBoolean("BrokenReferencesSupport.suppressBrokenRefAlert"); //NOI18N
private static final RequestProcessor RP = new RequestProcessor(BrokenReferencesImpl.class);
private static int BROKEN_ALERT_TIMEOUT = 1000;
+ // @GuardedBy(this)
private BrokenReferencesModel.Context context;
+ // @GuardedBy(this)
private RequestProcessor.Task rpTask;
+ // @GuardedBy(this)
+ private CompletableFuture<Void> runningFuture;
@Override
@@ -65,95 +71,121 @@ public class BrokenReferencesImpl implements BrokenReferencesImplementation {
"AD_Broken_References_Resolve_Panel_Close=N/A",
"LBL_Broken_References_Resolve_Panel_Title=Resolve Project Problems"
})
- public void showAlert(Project project) {
+ public CompletableFuture<Void> showAlert(Project project) {
+ CompletableFuture<Void> result;
Parameters.notNull("project", project); //NOI18N
+
if (!BrokenReferencesSettings.isShowAgainBrokenRefAlert() || suppressBrokenRefAlert) {
- return;
- } else if (context == null) {
- assert rpTask == null;
+ return CompletableFuture.completedFuture(null);
+ }
+
+ final BrokenReferencesModel.Context ctx;
+ final RequestProcessor.Task t;
+
+ synchronized (this) {
+ if (context == null) {
+ assert rpTask == null;
- final Runnable task = new Runnable() {
- @Override
- public void run() {
- final BrokenReferencesModel.Context ctx;
- synchronized (BrokenReferencesImpl.this) {
- rpTask = null;
- ctx = context;
- }
- if (ctx == null) {
- return;
- }
- try {
- final JButton resolveOption = new JButton(CTL_Broken_References_Resolve());
- resolveOption.getAccessibleContext().setAccessibleDescription(AD_Broken_References_Resolve());
- JButton closeOption = new JButton (CTL_Broken_References_Close());
- closeOption.getAccessibleContext().setAccessibleDescription(AD_Broken_References_Close());
- DialogDescriptor dd = new DialogDescriptor(new BrokenReferencesAlertPanel(),
- MSG_Broken_References_Title(),
- true,
- new Object[] {resolveOption, closeOption},
- closeOption,
- DialogDescriptor.DEFAULT_ALIGN,
- null,
- null);
- dd.setMessageType(DialogDescriptor.WARNING_MESSAGE);
- ctx.addChangeListener(new ChangeListener() {
- @Override
- public void stateChanged(ChangeEvent e) {
- resolveOption.setVisible(!ctx.isEmpty());
- }
- });
- resolveOption.setVisible(!ctx.isEmpty());
- if (DialogDisplayer.getDefault().notify(dd) == resolveOption) {
- final BrokenReferencesModel model = new BrokenReferencesModel(ctx, true);
- if (GraphicsEnvironment.isHeadless()) {
- fixAllProblems(model, new HashSet<>());
- return;
+ final Runnable task = new Runnable() {
+ @Override
+ public void run() {
+ final BrokenReferencesModel.Context ctx;
+ CompletableFuture<Void> res = null;
+ synchronized (BrokenReferencesImpl.this) {
+ rpTask = null;
+ ctx = context;
+ if (ctx == null) {
+ res = runningFuture;
+ runningFuture = null;
}
- final BrokenReferencesCustomizer customizer = new BrokenReferencesCustomizer(model);
- JButton close = new JButton (Bundle.LBL_Broken_References_Resolve_Panel_Close());
- close.getAccessibleContext ().setAccessibleDescription (Bundle.AD_Broken_References_Resolve_Panel_Close());
- dd = new DialogDescriptor(customizer,
- Bundle.LBL_Broken_References_Resolve_Panel_Title(),
+ }
+ if (res != null) {
+ res.complete(null);
+ return;
+ }
+ try {
+ final JButton resolveOption = new JButton(CTL_Broken_References_Resolve());
+ resolveOption.getAccessibleContext().setAccessibleDescription(AD_Broken_References_Resolve());
+ JButton closeOption = new JButton (CTL_Broken_References_Close());
+ closeOption.getAccessibleContext().setAccessibleDescription(AD_Broken_References_Close());
+ DialogDescriptor dd = new DialogDescriptor(new BrokenReferencesAlertPanel(),
+ MSG_Broken_References_Title(),
true,
- new Object[] {closeOption},
+ new Object[] {resolveOption, closeOption},
closeOption,
DialogDescriptor.DEFAULT_ALIGN,
null,
null);
- customizer.setNotificationLineSupport(dd.createNotificationLineSupport());
- DialogDisplayer.getDefault().notify(dd);
- }
- } finally {
- synchronized (BrokenReferencesImpl.this) {
- //Clean seen references and start from empty list
- context = null;
+ dd.setMessageType(DialogDescriptor.WARNING_MESSAGE);
+ ctx.addChangeListener(new ChangeListener() {
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ resolveOption.setVisible(!ctx.isEmpty());
+ }
+ });
+ resolveOption.setVisible(!ctx.isEmpty());
+ if (DialogDisplayer.getDefault().notify(dd) == resolveOption) {
+ final BrokenReferencesModel model = new BrokenReferencesModel(ctx, true);
+ if (GraphicsEnvironment.isHeadless()) {
+ fixAllProblems(model, new HashSet<>());
+ return;
+ }
+ final BrokenReferencesCustomizer customizer = new BrokenReferencesCustomizer(model);
+ JButton close = new JButton (Bundle.LBL_Broken_References_Resolve_Panel_Close());
+ close.getAccessibleContext ().setAccessibleDescription (Bundle.AD_Broken_References_Resolve_Panel_Close());
+ dd = new DialogDescriptor(customizer,
+ Bundle.LBL_Broken_References_Resolve_Panel_Title(),
+ true,
+ new Object[] {closeOption},
+ closeOption,
+ DialogDescriptor.DEFAULT_ALIGN,
+ null,
+ null);
+ customizer.setNotificationLineSupport(dd.createNotificationLineSupport());
+ DialogDisplayer.getDefault().notify(dd);
+ }
+ } finally {
+ synchronized (BrokenReferencesImpl.this) {
+ //Clean seen references and start from empty list
+ context = null;
+ res = runningFuture;
+ runningFuture = null;
+ }
+ res.complete(null);
}
}
- }
- };
-
- context = new BrokenReferencesModel.Context();
- rpTask = RP.create(new Runnable() {
- @Override
- public void run() {
- if (GraphicsEnvironment.isHeadless()) {
- task.run();
- } else {
- WindowManager.getDefault().invokeWhenUIReady(task);
+ };
+
+ context = new BrokenReferencesModel.Context();
+ rpTask = RP.create(new Runnable() {
+ @Override
+ public void run() {
+ if (GraphicsEnvironment.isHeadless()) {
+ task.run();
+ } else {
+ WindowManager.getDefault().invokeWhenUIReady(task);
+ }
}
- }
- });
+ });
+ this.runningFuture = new CompletableFuture<>();
+ return runningFuture;
+ }
+
+ ctx = this.context;
+ result = this.runningFuture;
+ t = this.rpTask;
}
- assert context != null;
+ assert ctx != null;
+ assert result != null;
if (project != null) {
- context.offer(project);
+ ctx.offer(project);
}
- if (rpTask != null) {
+ if (t != null) {
//Not yet shown, move
- rpTask.schedule(BROKEN_ALERT_TIMEOUT);
+ t.schedule(BROKEN_ALERT_TIMEOUT);
}
+ return result;
}
@NbBundle.Messages({
@@ -197,7 +229,8 @@ public class BrokenReferencesImpl implements BrokenReferencesImplementation {
"LBL_BrokenLinksCustomizer_Title=Resolve Project Problems - \"{0}\" Project"
})
@Override
- public void showCustomizer(@NonNull Project project) {
+ public CompletableFuture<Void> showCustomizer(@NonNull Project project) {
+ CompletableFuture<Void> result = new CompletableFuture<>();
Parameters.notNull("project", project); //NOI18N
BrokenReferencesModel model = new BrokenReferencesModel(project);
BrokenReferencesCustomizer customizer = new BrokenReferencesCustomizer(model);
@@ -208,15 +241,30 @@ public class BrokenReferencesImpl implements BrokenReferencesImplementation {
LBL_BrokenLinksCustomizer_Title(projectDisplayName), // NOI18N
true, new Object[] {close}, close, DialogDescriptor.DEFAULT_ALIGN, null, null);
customizer.setNotificationLineSupport(dd.createNotificationLineSupport());
- Dialog dlg = null;
- try {
- dlg = DialogDisplayer.getDefault().createDialog(dd);
- dlg.setVisible(true);
- } finally {
- if (dlg != null) {
- dlg.dispose();
+ Runnable r = new Runnable() {
+ public void run() {
+ Dialog dlg = null;
+ try {
+ dlg = DialogDisplayer.getDefault().createDialog(dd);
+ if (SwingUtilities.isEventDispatchThread()) {
+ dlg.setVisible(true);
+ }
+ } catch (RuntimeException ex) {
+ result.completeExceptionally(ex);
+ } finally {
+ if (dlg != null) {
+ dlg.dispose();
+ }
+ result.complete(null);
+ }
}
+ };
+ if (SwingUtilities.isEventDispatchThread()) {
+ r.run();
+ } else {
+ SwingUtilities.invokeLater(r);
}
+ return result;
}
}
diff --git a/ide/projectuiapi/apichanges.xml b/ide/projectuiapi/apichanges.xml
index f948174..7d9158a 100644
--- a/ide/projectuiapi/apichanges.xml
+++ b/ide/projectuiapi/apichanges.xml
@@ -83,6 +83,21 @@ is the proper place.
<!-- ACTUAL CHANGES BEGIN HERE: -->
<changes>
+ <change id="project-problems-implementation">
+ <api name="general"/>
+ <summary>Allow pluggable implementation for ProjectProblems API.</summary>
+ <version major="1" minor="105"/>
+ <date day="22" month="2" year="2022"/>
+ <author login="sdedic"/>
+ <compatibility addition="yes"/>
+ <description>
+ <p>
+ <a href="@TOP@/org/netbeans/api/project/ui/ProjectProblems.html">ProjectProblems API</a> now delegates to
+ <a href="@TOP@/org/netbeans/spi/project/ui/ProjectProblemsImplementation .html">ProjectProblemsImplementation</a>
+ registered in default Lookup.
+ </p>
+ </description>
+ </change>
<change id="newProjectAction.preselectCategory">
<api name="general"/>
<summary>Provide a method to create an action that invokes the new project wizard,
diff --git a/ide/projectuiapi/nbproject/project.properties b/ide/projectuiapi/nbproject/project.properties
index c34da8a..b52ab37 100644
--- a/ide/projectuiapi/nbproject/project.properties
+++ b/ide/projectuiapi/nbproject/project.properties
@@ -17,7 +17,7 @@
javac.compilerargs=-Xlint -Xlint:-serial
javac.source=1.8
-spec.version.base=1.104.0
+spec.version.base=1.105.0
is.autoload=true
javadoc.arch=${basedir}/arch.xml
javadoc.apichanges=${basedir}/apichanges.xml
diff --git a/ide/projectuiapi/src/org/netbeans/api/project/ui/ProjectProblems.java b/ide/projectuiapi/src/org/netbeans/api/project/ui/ProjectProblems.java
index 9e8261c..942ed28 100644
--- a/ide/projectuiapi/src/org/netbeans/api/project/ui/ProjectProblems.java
+++ b/ide/projectuiapi/src/org/netbeans/api/project/ui/ProjectProblems.java
@@ -18,9 +18,11 @@
*/
package org.netbeans.api.project.ui;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
import org.netbeans.api.annotations.common.NonNull;
import org.netbeans.api.project.Project;
-import org.netbeans.modules.project.uiapi.BrokenReferencesImplementation;
+import org.netbeans.spi.project.ui.ProjectProblemsImplementation;
import org.netbeans.spi.project.ui.ProjectProblemsProvider;
import org.openide.util.Lookup;
import org.openide.util.Parameters;
@@ -68,21 +70,34 @@ public class ProjectProblems {
*/
public static void showAlert(@NonNull final Project project) {
Parameters.notNull("project", project); //NOI18N
- final BrokenReferencesImplementation impl = Lookup.getDefault().lookup(BrokenReferencesImplementation.class);
+ final ProjectProblemsImplementation impl = Lookup.getDefault().lookup(ProjectProblemsImplementation.class);
if (impl != null) {
impl.showAlert(project);
}
}
-
+
/**
* Shows an UI customizer which gives users chance to fix encountered problems.
* @param project the project for which the customizer should be shown.
*/
public static void showCustomizer(@NonNull final Project project) {
Parameters.notNull("project", project); //NOI18N
- final BrokenReferencesImplementation impl = Lookup.getDefault().lookup(BrokenReferencesImplementation.class);
+ final ProjectProblemsImplementation impl = Lookup.getDefault().lookup(ProjectProblemsImplementation.class);
if (impl != null) {
- impl.showCustomizer(project);
+ try {
+ // compatibility: wait for the process to complete.
+ impl.showCustomizer(project).get();
+ } catch (InterruptedException ex) {
+ throw new CompletionException(ex);
+ } catch (ExecutionException ex) {
+ if (ex.getCause() instanceof Error) {
+ throw (Error)ex.getCause();
+ }
+ if (ex.getCause() instanceof RuntimeException) {
+ throw (RuntimeException)ex.getCause();
+ }
+ throw new CompletionException(ex.getCause());
+ }
}
}
diff --git a/ide/projectuiapi/src/org/netbeans/spi/project/ui/ProjectProblemsImplementation.java b/ide/projectuiapi/src/org/netbeans/spi/project/ui/ProjectProblemsImplementation.java
new file mode 100644
index 0000000..585edc8
--- /dev/null
+++ b/ide/projectuiapi/src/org/netbeans/spi/project/ui/ProjectProblemsImplementation.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.spi.project.ui;
+
+import java.util.concurrent.CompletableFuture;
+import org.netbeans.api.annotations.common.NonNull;
+import org.netbeans.api.project.Project;
+
+/**
+ * Interface that plugs in the actual implementation of UI for {@link ProjectProblems} API.
+ *
+ * @author Tomas Zezula, Svatopluk Dedic
+ */
+public interface ProjectProblemsImplementation {
+
+ /**
+ * Show alert message box informing user that a project has problems (broken references).
+ * references. The implementation should handle gracefully repeated alerts for the same
+ * project, by e.g. ignoring requests to alert for a project that has still its problem
+ * resolution UI opened.
+ *
+ * @param project to show the alert for.
+ * @return future that will be completed when the user finishes the UI.
+ */
+ CompletableFuture<Void> showAlert(@NonNull Project project);
+
+ /**
+ * Shows a customizer, or another UI to handle project problems.
+ * @param project whose problems should be resolved.
+ * @return future that will be completed once the customizer finishes.
+ */
+ CompletableFuture<Void> showCustomizer(@NonNull Project project);
+}
diff --git a/java/java.lsp.server/nbcode/integration/nbproject/project.xml b/java/java.lsp.server/nbcode/integration/nbproject/project.xml
index 10d20aa..f19f6fb 100644
--- a/java/java.lsp.server/nbcode/integration/nbproject/project.xml
+++ b/java/java.lsp.server/nbcode/integration/nbproject/project.xml
@@ -81,7 +81,16 @@
<build-prerequisite/>
<compile-dependency/>
<run-dependency>
- <specification-version>1.16</specification-version>
+ <specification-version>1.19</specification-version>
+ </run-dependency>
+ </dependency>
+ <dependency>
+ <code-name-base>org.netbeans.modules.projectuiapi</code-name-base>
+ <build-prerequisite/>
+ <compile-dependency/>
+ <run-dependency>
+ <release-version>1</release-version>
+ <specification-version>1.104.0.8</specification-version>
</run-dependency>
</dependency>
<dependency>
diff --git a/java/java.lsp.server/nbcode/integration/src/org/netbeans/modules/nbcode/integration/LspBrokenReferencesImpl.java b/java/java.lsp.server/nbcode/integration/src/org/netbeans/modules/nbcode/integration/LspBrokenReferencesImpl.java
new file mode 100644
index 0000000..19bcc91
--- /dev/null
+++ b/java/java.lsp.server/nbcode/integration/src/org/netbeans/modules/nbcode/integration/LspBrokenReferencesImpl.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.nbcode.integration;
+
+import org.netbeans.modules.java.lsp.server.ui.AbstractLspBrokenReferences;
+import org.netbeans.spi.project.ui.ProjectProblemsImplementation;
+import org.openide.util.lookup.ServiceProvider;
+
+/**
+ *
+ * @author sdedic
+ */
+@ServiceProvider(service = ProjectProblemsImplementation.class, position = 1000)
+public class LspBrokenReferencesImpl extends AbstractLspBrokenReferences {
+
+ public LspBrokenReferencesImpl() {
+ }
+
+}
diff --git a/java/java.lsp.server/nbproject/project.properties b/java/java.lsp.server/nbproject/project.properties
index 4c44f5b..8394988 100644
--- a/java/java.lsp.server/nbproject/project.properties
+++ b/java/java.lsp.server/nbproject/project.properties
@@ -17,7 +17,7 @@
javac.source=1.8
javac.compilerargs=-Xlint -Xlint:-serial
-spec.version.base=1.18.0
+spec.version.base=1.19.0
javadoc.arch=${basedir}/arch.xml
requires.nb.javac=true
lsp.build.dir=vscode/nbcode
diff --git a/java/java.lsp.server/nbproject/project.xml b/java/java.lsp.server/nbproject/project.xml
index 8c8c0ec..93fbe85 100644
--- a/java/java.lsp.server/nbproject/project.xml
+++ b/java/java.lsp.server/nbproject/project.xml
@@ -462,6 +462,17 @@
<specification-version>1.101</specification-version>
</run-dependency>
</dependency>
+
+ <dependency>
+ <code-name-base>org.netbeans.modules.projectuiapi</code-name-base>
+ <build-prerequisite/>
+ <compile-dependency/>
+ <run-dependency>
+ <release-version>1</release-version>
+ <implementation-version/>
+ </run-dependency>
+ </dependency>
+
<dependency>
<code-name-base>org.netbeans.modules.queries</code-name-base>
<build-prerequisite/>
@@ -738,6 +749,7 @@
<test-dependency>
<code-name-base>org.netbeans.modules.projectui</code-name-base>
<recursive/>
+ <compile-dependency/>
</test-dependency>
<test-dependency>
<code-name-base>org.netbeans.modules.settings</code-name-base>
diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/BrokenReferencesImpl.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/BrokenReferencesImpl.java
new file mode 100644
index 0000000..79f26cf
--- /dev/null
+++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/BrokenReferencesImpl.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.java.lsp.server.project;
+
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.CompletableFuture;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.netbeans.api.annotations.common.NonNull;
+import org.netbeans.api.project.Project;
+import org.netbeans.spi.project.ui.ProjectProblemsImplementation;
+
+/**
+ * Implements the ProjectProblem resolution process in a way more suitable for LSP. When a project Alert
+ * or Customizer is requested, a ProjectAlertPresenter is created and will be live until the user processes
+ * all the alerts, or the responses time out. With a new alert, the alerts will show up again.
+ * Each Presenter works with a single Project.
+ * <p>
+ * When fatal (non-resolvable) problems occur, they are reported using ShowMessage requests. Once they are
+ * confirmed, resolvable issues will be processed. There's a timeout {@link #WAKEUP_DELAY} for the user
+ * inactivity after last user's response (message ok/dismiss): if no response is received, the process
+ * continues.
+ * <p>
+ * If there are recoverable problems, the user is presented with the first one's description. If there are
+ * more problems, a note "There are XX other fixable problems." is added.
+ * <p>
+ * The main loop supports two modes: autoresolve true will immediately attempt to resolve 1st resolvable
+ * problem, while autoresolve false provides the description and asks for confirmation. This allows the
+ * "OK" and "Rest" button to switch between the two modes:
+ * <ul>
+ * <li>resolve first, report issues if any
+ * <li>ask with details about the fix first, then resolve
+ * </ul>
+ * When the user is asked to decide, a timeout task is scheduled: if the user does not respond within
+ * the defined timeout, the process continues.
+ * <p>
+ * To avoid possible issues with multiple project actions, <b>all resolve calls from all ProblemResolvers</b>
+ * are serialized to a single dedicated RP.
+ * <p/>
+ * A request to <b>alert</b> the project will just push the process further: if timeout is under way, it will
+ * terminate it and display the next question.
+ * <p/>
+ * A request to <b>display a customizer</b> will invalidate the current presenter, and display all the issues
+ * anew. This allows for an explicit CodeAction or Command that will reiterate questions the user may have closed.
+ * <p/>
+ * Note: if a ShowMessage request is delivered to vscode client and the same ShowMessage request is pending, the client
+ * will cancel the former ShowMessage as if the user pressed ESC or dismissed the message. The new ShowMessage will be
+ * displayed
+ * @author sdedic
+ */
+public class BrokenReferencesImpl implements ProjectProblemsImplementation, ProjectAlertPresenter.Env {
+ private static final Logger LOG = Logger.getLogger(BrokenReferencesImpl.class.getName());
+
+ // @GuardedBy(this)
+ /**
+ * Holds all alerted projects. Will be freed after the last project's Presenter will
+ * be removed.
+ */
+ private BrokenReferencesModel.Context context;
+
+ /**
+ * Active Presenters
+ */
+ // @GuadedBy(this)
+ private final Map<Project, ProjectAlertPresenter> presenters = new WeakHashMap<>();
+
+ @Override
+ public CompletableFuture<Void> showAlert(@NonNull Project project) {
+ ProjectAlertPresenter p;
+
+ synchronized (this) {
+ if (context == null) {
+ context = new BrokenReferencesModel.Context();
+ LOG.log(Level.FINEST, "Initializing new Context");
+ }
+ context.offer(project);
+
+ p = presenters.computeIfAbsent(project, (p2) -> createPresenter(p2, new BrokenReferencesModel(context, true)));
+ }
+ p.cleanAndProcess(false);
+ return p.getCompletion().thenApply(x -> null);
+ }
+
+ ProjectAlertPresenter createPresenter(Project project, BrokenReferencesModel model) {
+ return new ProjectAlertPresenter(project, model, this);
+ }
+
+ @Override
+ public CompletableFuture<Void> showCustomizer(@NonNull Project project) {
+ return fixBrokenProject(project).thenApply(x -> null);
+ }
+
+ public CompletableFuture<Boolean> fixBrokenProject(@NonNull Project project) {
+ ProjectAlertPresenter p;
+
+ synchronized (this) {
+ p = new ProjectAlertPresenter(project, new BrokenReferencesModel(context, true), this);
+ presenters.put(project, p);
+ }
+ p.processProject(true);
+ return p.getCompletion();
+ }
+
+ /**
+ * Checks if the presenter is the active one for the project.
+ * @param p presenter to check
+ * @return true, if the presenter is active
+ */
+ @Override
+ public boolean isActivePresenter(ProjectAlertPresenter p) {
+ synchronized (this) {
+ return p == presenters.get(p.getProject());
+ }
+ }
+
+ /**
+ * Finishes the work on a project. If the presenter is the active one, the method will
+ * remove the presenter from the presenters queue. Removing the last presenter will also
+ * release context, that is all project references.
+ *
+ * @param presenter presenter to terminate
+ * @param p the presenter's project
+ */
+ @Override
+ public void finishProject(ProjectAlertPresenter presenter) {
+ synchronized (this) {
+ presenter.completion.complete(presenter.allProcessed);
+ if (!presenters.remove(presenter.getProject(), presenter)) {
+ return;
+ }
+ if (presenters.isEmpty()) {
+ context = null;
+ }
+ }
+ }
+
+ /* tests only */
+ ProjectAlertPresenter getPresenter(Project p) {
+ synchronized (this) {
+ return presenters.get(p);
+ }
+ }
+}
diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/BrokenReferencesModel.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/BrokenReferencesModel.java
new file mode 100644
index 0000000..b686c35
--- /dev/null
+++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/BrokenReferencesModel.java
@@ -0,0 +1,318 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.netbeans.modules.java.lsp.server.project;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import javax.swing.AbstractListModel;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import org.netbeans.api.annotations.common.NonNull;
+import org.netbeans.api.project.Project;
+import org.netbeans.api.project.ProjectManager;
+import org.netbeans.api.project.ProjectUtils;
+import org.openide.util.ChangeSupport;
+import org.netbeans.spi.project.ui.ProjectProblemsProvider;
+import org.netbeans.spi.project.ui.ProjectProblemsProvider.ProjectProblem;
+import org.openide.util.Mutex;
+import org.openide.util.NbBundle;
+import org.openide.util.WeakListeners;
+
+/**
+ * This is a copy of broken references model from Project UI API, slightly improved.
+ *
+ */
+public final class BrokenReferencesModel extends AbstractListModel implements PropertyChangeListener, ChangeListener {
+
+ private static final Logger LOG = Logger.getLogger(BrokenReferencesModel.class.getName());
+
+ private final Context ctx;
+ private final boolean global;
+ private final Object lock = new Object();
+ //@GuardedBy("lock")
+ private final Map<ProjectProblemsProvider,PropertyChangeListener> providers;
+ //@GuardedBy("lock")
+ private final List<ProblemReference> problems;
+
+
+ BrokenReferencesModel(@NonNull final Context ctx, boolean global) {
+ assert ctx != null;
+ this.ctx = ctx;
+ this.global = global;
+ problems = new ArrayList<ProblemReference>();
+ providers = new WeakHashMap<ProjectProblemsProvider, PropertyChangeListener>();
+ refresh();
+ ctx.addChangeListener(this);
+ }
+
+ BrokenReferencesModel(@NonNull final Project project) {
+ this(new Context(), false);
+ this.ctx.offer(project);
+ }
+
+ @Override
+ public Object getElementAt(int index) {
+ return getOneReference(index);
+ }
+
+ @Override
+ public int getSize() {
+ synchronized (lock) {
+ return problems.size();
+ }
+ }
+
+ public List<Project> projects() {
+ synchronized (this) {
+ return problems.stream().sequential().map(r -> r.project).collect(Collectors.toList());
+ }
+ }
+
+ public List<ProblemReference> projectProblems(Project project, boolean includeSeen) {
+ synchronized (this) {
+ return problems.stream().sequential().filter(r -> r.project == project).collect(Collectors.toList());
+ }
+ }
+
+ void refresh() {
+ refresh(true);
+ }
+
+
+ /**
+ * Refreshes the model + allows to suppress change events. Call refresh(false) from problem processing methods to avoid
+ * recursive invocation, as the change events are fired even though no problems are added/removed.
+ * @param fire
+ */
+ void refresh(boolean fire) {
+ AtomicBoolean changed = new AtomicBoolean(false);
+ final int size = ProjectManager.mutex().readAccess(new Mutex.Action<Integer>() {
+ @Override
+ public Integer run() {
+ synchronized (lock) {
+ final Map<ProjectProblemsProvider,Project> newProviders = new LinkedHashMap<ProjectProblemsProvider,Project>();
+ for (Project bprj : ctx.getBrokenProjects()) {
+ final ProjectProblemsProvider provider = bprj.getLookup().lookup(ProjectProblemsProvider.class);
+ if (provider != null) {
+ newProviders.put(provider, bprj);
+ }
+ }
+ for (Iterator<Map.Entry<ProjectProblemsProvider,PropertyChangeListener>> it = providers.entrySet().iterator(); it.hasNext();) {
+ final Map.Entry<ProjectProblemsProvider,PropertyChangeListener> e = it.next();
+ if (!newProviders.containsKey(e.getKey())) {
+ e.getKey().removePropertyChangeListener(e.getValue());
+ it.remove();
+ }
+ }
+ final Set<ProblemReference> all = new LinkedHashSet<ProblemReference>();
+ for (Map.Entry<ProjectProblemsProvider,Project> ne : newProviders.entrySet()) {
+ final ProjectProblemsProvider ppp = ne.getKey();
+ final Project bprj = ne.getValue();
+ if (!providers.containsKey(ppp)) {
+ final PropertyChangeListener l = WeakListeners.propertyChange(BrokenReferencesModel.this, ppp);
+ ppp.addPropertyChangeListener(l);
+ providers.put(ppp, l);
+ }
+ for (ProjectProblem problem : ppp.getProblems()) {
+ all.add(new ProblemReference(problem, bprj, global));
+ }
+ }
+ changed.set(updateReferencesList(problems, all));
+ return getSize();
+ }
+ }
+ });
+ if (fire && changed.get()) {
+ Mutex.EVENT.readAccess(new Runnable() {
+ @Override
+ public void run() {
+ fireContentsChanged(BrokenReferencesModel.this, 0, size);
+ }
+ });
+ }
+ }
+
+ private ProblemReference getOneReference(int index) {
+ synchronized (lock) {
+ assert index>=0 && index<problems.size();
+ return problems.get(index);
+ }
+ }
+
+ private static boolean updateReferencesList(List<ProblemReference> oldBroken, Set<ProblemReference> newBroken) {
+ boolean change = false;
+ LOG.log(Level.FINE, "References updated from {0} to {1}", new Object[] {oldBroken, newBroken});
+ for (ProblemReference or : oldBroken) {
+ or.resolved = !newBroken.contains(or);
+ }
+ for (ProblemReference or : newBroken) {
+ if (!oldBroken.contains(or)) {
+ change = true;
+ oldBroken.add(or);
+ }
+ }
+ return change;
+ }
+
+ @Override
+ public void propertyChange(PropertyChangeEvent evt) {
+ if (ProjectProblemsProvider.PROP_PROBLEMS.equals(evt.getPropertyName())) {
+ refresh();
+ }
+ }
+
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ refresh();
+ }
+
+ static final class ProblemReference {
+ private final boolean global;
+ private final Project project;
+ final ProjectProblem problem;
+
+
+ volatile boolean resolved;
+ volatile boolean seen;
+
+ ProblemReference(
+ @NonNull final ProjectProblem problem,
+ @NonNull final Project project,
+ final boolean global) {
+ assert problem != null;
+ this.problem = problem;
+ this.project = project;
+ this.global = global;
+ }
+
+
+ String getDisplayName() {
+ final String displayName = problem.getDisplayName();
+ String message;
+ if (global) {
+ final String projectName = ProjectUtils.getInformation(project).getDisplayName();
+ message = NbBundle.getMessage(
+ BrokenReferencesModel.class,
+ "FMT_ProblemInProject",
+ projectName,
+ displayName);
+
+ } else {
+ message = displayName;
+ }
+ return message;
+
+
+ }
+
+ public Project getProject() {
+ return project;
+ }
+
+ public void markSeen() {
+ seen = true;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ return String.format(
+ "Problem: %s %s", //NOI18N
+ problem,
+ resolved ? "resolved" : "unresolved"); //NOI18N
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = 17;
+ hash = 31 * hash + problem.hashCode();
+ hash = 31 * hash + project.hashCode();
+ return hash;
+ }
+
+ @Override
+ public boolean equals (Object other) {
+ if (!(other instanceof ProblemReference)) {
+ return false;
+ }
+ final ProblemReference otherRef = (ProblemReference) other;
+ return problem.equals(otherRef.problem) &&
+ project.equals(otherRef.project);
+ }
+
+ }
+
+ static final class Context {
+ private final Set<Project> toResolve;
+ private final ChangeSupport support;
+
+ public Context() {
+ toResolve = Collections.synchronizedSet(new LinkedHashSet<Project>());
+ support = new ChangeSupport(this);
+ }
+
+ public boolean offer(@NonNull final Project broken) {
+ assert broken != null;
+ if (broken.getLookup().lookup(ProjectProblemsProvider.class) != null) {
+ boolean r = this.toResolve.add(broken);
+ if (r) {
+ support.fireChange();
+ }
+ return r;
+ } else {
+ return false;
+ }
+ }
+
+ public boolean isEmpty() {
+ return this.toResolve.isEmpty();
+ }
+
+ public Project[] getBrokenProjects() {
+ synchronized (toResolve) {
+ return toResolve.toArray(new Project[toResolve.size()]);
+ }
+ }
+
+ public void addChangeListener(final @NonNull ChangeListener listener) {
+ assert listener != null;
+ support.addChangeListener(listener);
+ }
+
+ public void removeChangeListener(final @NonNull ChangeListener listener) {
+ assert listener != null;
+ support.removeChangeListener(listener);
+ }
+ }
+
+}
diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/ProjectAlertPresenter.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/ProjectAlertPresenter.java
new file mode 100644
index 0000000..0482e7c
--- /dev/null
+++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/project/ProjectAlertPresenter.java
@@ -0,0 +1,808 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.java.lsp.server.project;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.function.BiFunction;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import javax.swing.event.ListDataEvent;
+import javax.swing.event.ListDataListener;
+import org.netbeans.api.project.Project;
+import org.netbeans.api.project.ProjectInformation;
+import org.netbeans.api.project.ProjectUtils;
+import org.netbeans.spi.project.ui.ProjectProblemsProvider;
+import org.openide.DialogDisplayer;
+import org.openide.NotifyDescriptor;
+import org.openide.awt.StatusDisplayer;
+import org.openide.util.NbBundle;
+import org.openide.util.RequestProcessor;
+
+/**
+ *
+ * @author sdedic
+ */
+@NbBundle.Messages(value = {
+ "# {0} - project name",
+ "# {1} - problem's display name",
+ "ProjectProblem_Title=Project {0}: {1}",
+
+ "# {0} - project name",
+ "ProjectProblems_Resolved_Info=Project {0} issues resolved",
+
+ "# {0} - project name",
+ "ProjectProblems_Resolved_Warning=Project {0} issues resolved",
+
+ "# {0} - project name",
+ "ProjectProblems_Resolved_Error=Project {0} issues not fixed",
+
+ "# {0} - problem display",
+ "# {1} - message",
+ "ProjectProblems_Resolved_ErrorMessage1=The fix for \"{0}\" failed: {1}",
+
+ "# {0} - problem display",
+ "# {1} - message",
+ "ProjectProblems_Resolved_WarningMessage1=Problem \"{0}\" was resolved: {1}",
+
+ "# {0} - problem display",
+ "# {1} - message",
+ "# {2} - other errors note",
+ "ProjectProblems_Resolved_ErrorMessage2=The fix for \"{0}\" failed: {1}. {2}",
+
+ "# {0} - problem display",
+ "# {1} - message",
+ "ProjectProblems_Resolved_WarningMessage2=Problem \"{0}\" was resolved: {1}",
+
+ "# {0} - number of fixes",
+ "# {1} - the main message",
+ "ProjectProblems_Additional={1}. Additional fixes ({0}) are available."})
+class ProjectAlertPresenter {
+ private static final Logger LOG = Logger.getLogger(ProjectAlertPresenter.class.getName());
+
+ static int MAX_PRESENTED_ERRORS = Integer.getInteger(ProjectAlertPresenter.class.getName() + ".maxErrors", 10);
+ static int ERRORS_WAKEUP_DELAY = Integer.getInteger(ProjectAlertPresenter.class.getName() + ".errorsWakeUp", 2 * 60 * 1000);
+ static int QUESTION_WAKEUP_DELAY = Integer.getInteger(ProjectAlertPresenter.class.getName() + ".questionWakeUp", 5 * 60 * 1000);
+
+ /**
+ * All resolutions are executed in this thread, to prevent interactions between project actions.
+ */
+ private static final RequestProcessor RESOLVE_RP = new RequestProcessor(BrokenReferencesImpl.class.getName());
+
+ /**
+ * Mainly a 'watcher' thread that continues the process if the user does not react on prompts.
+ */
+ private static final RequestProcessor RP = new RequestProcessor(BrokenReferencesImpl.class.getName());
+
+ private static final int WAKEUP_DELAY = 2 * 60 * 1000;
+
+
+ /**
+ * The project.
+ */
+ private final Project project;
+
+ /**
+ * Project name.
+ */
+ private final String projectName;
+
+ /**
+ * Updating model of the project's problems.
+ */
+ private final BrokenReferencesModel model;
+
+ /**
+ * The controller.
+ */
+ private final Env controller;
+
+ /**
+ * Problems that were already seen and presented to the user.
+ */
+ private final Set<ProjectProblemsProvider.ProjectProblem> seen = Collections.synchronizedSet(new HashSet<>());
+
+ final Object restOption = Bundle.ProjectProblems_RestOption();
+ final Object detailOption = Bundle.ProjectProblems_DetailsOption();
+
+ /**
+ * Overall result. The future will complete after all errors were seen, all questions were answered or timed out.
+ */
+ final CompletableFuture<Boolean> completion;
+
+ /**
+ * All errors already acknowledged by the process, but not necessarily presented
+ */
+ // @GuardedBy(this)
+ Set<BrokenReferencesModel.ProblemReference> snapshot = Collections.emptySet();
+
+ /**
+ * Invocation attempt, i.e. after a timeout or new project alert. The process continues after user response only
+ * if another run attempt has not started yet.
+ */
+ // @GuardedBy(this)
+ int runAttempt;
+
+ /**
+ * If true, will re-display project problems that have been already presented to the client.
+ */
+ boolean includeSeen;
+
+ /**
+ * Timer task that will push the process further if the user ignores the prompts. The task is run by {@link #RP} processor.
+ */
+ // @GuardedBy(this)
+ private RequestProcessor.Task wakeUpTask;
+
+ /**
+ * The resolution process is cancelled. The process will terminate after the first timeout or user input.
+ */
+ // @GuardedBy(this)
+ private volatile CancellationException cancelled;
+
+ /**
+ * Indicates that all questions were answered. Will eventually reset to {@code false} during the process, if the user
+ * cancels further fixes.
+ */
+ // @GuardedBy(this)
+ boolean allProcessed = true;
+
+ public ProjectAlertPresenter(Project project, BrokenReferencesModel model, final Env master) {
+ this.controller = master;
+ this.project = project;
+ this.model = model;
+ ProjectInformation pi = ProjectUtils.getInformation(project);
+ String pn = pi.getDisplayName();
+ if (pn == null) {
+ pn = pi.getName();
+ }
+ this.projectName = pn;
+ LOG.log(Level.FINE, "Initializing alert presenter for {0}", projectName);
+ completion = new CompletableFuture<Boolean>() {
+ @Override
+ public boolean cancel(boolean mayInterruptIfRunning) {
+ boolean r = super.cancel(mayInterruptIfRunning);
+ if (r) {
+ ProjectAlertPresenter.this.cancel();
+ }
+ return r;
+ }
+ };
+ model.addListDataListener(new ListDataListener() {
+ @Override
+ public void intervalAdded(ListDataEvent e) {
+ awake();
+ }
+
+ @Override
+ public void intervalRemoved(ListDataEvent e) {
+
+ }
+
+ @Override
+ public void contentsChanged(ListDataEvent e) {
+ awake();
+ }
+ });
+ }
+
+ public Project getProject() {
+ return project;
+ }
+
+ public CompletableFuture<Boolean> getCompletion() {
+ return completion;
+ }
+
+ public void cancel() {
+ synchronized (this) {
+ cancelled = new CancellationException();
+ if (wakeUpTask != null) {
+ wakeUpTask.cancel();
+ }
+ }
+ }
+
+ /**
+ * Resumes the process after user timeout.
+ * @param autoResolve
+ */
+ void resumeAfterTimeout(Ctx ctx) {
+ assert RP.isRequestProcessorThread();
+ LOG.log(Level.FINE, "User unresponsive for project {0}, continue", projectName);
+ ErrorQueue oq;
+ synchronized (this) {
+ if (!ctx.valid()) {
+ return;
+ }
+ if (wakeUpTask != null && !wakeUpTask.isFinished()) {
+ wakeUpTask.cancel();
+ wakeUpTask = null;
+ }
+ oq = errorQueue;
+ errorQueue = null;
+ runAttempt++;
+ ctx = new Ctx(ctx.autoResolve);
+ }
+ if (oq != null) {
+ oq.terminate(true);
+ }
+ processOneRound(ctx);
+ }
+
+ private void awake() {
+ processProject(false);
+ }
+
+ Set<ProjectProblemsProvider.ProjectProblem> seenProblems() {
+ return new LinkedHashSet<>(this.seen);
+ }
+
+ public void cleanAndProcess(boolean autoResolve) {
+ synchronized (this) {
+ if (wakeUpTask != null) {
+ wakeUpTask.cancel();
+ }
+ wakeUpTask = null;
+ snapshot = Collections.emptySet();
+ }
+ processProject(autoResolve);
+ }
+
+ /**
+ * Starts or restarts processing. If {@code autoResolve} is {@code true},
+ * @param autoResolve
+ */
+ public void processProject(boolean autoResolve) {
+ model.refresh(false);
+ Ctx ctx;
+ synchronized (this) {
+ List<BrokenReferencesModel.ProblemReference> probs = new ArrayList<>();
+ List<BrokenReferencesModel.ProblemReference> fatals = findProblems(probs);
+ LOG.log(Level.FINE, "Processing {0} fatals, {1} resolvables of {2}", new Object[]{fatals.size(), probs.size(), projectName});
+ if (snapshot.containsAll(probs) && snapshot.containsAll(fatals) && wakeUpTask != null) {
+ // activate only if something new happens, there's timeout task that will eventually
+ // continue if user is unresponsive.
+ LOG.log(Level.FINE, "All already seen or being processed, wakeup task available");
+ return;
+ }
+ if (seen.containsAll(fatals) && wakeUpTask != null) {
+ // all fatals reported, but user did not dismissed - a new fix will be handled by the
+ // running process.
+ LOG.log(Level.FINE, "Fatals reported, wakeup task available, wait for user or timeout");
+ return;
+ }
+ if (wakeUpTask != null && !wakeUpTask.isFinished()) {
+ if (!wakeUpTask.cancel()) {
+ // will invoke process anyway
+ return;
+ }
+ }
+ if (cancelled != null) {
+ finishThis();
+ return;
+ }
+ runAttempt++;
+ ctx = new Ctx(autoResolve);
+ LOG.log(Level.FINE, "Wakeup cancelled, new presenter run");
+ }
+ processOneRound(ctx);
+ }
+
+ private List<BrokenReferencesModel.ProblemReference> findProblems(List<BrokenReferencesModel.ProblemReference> probs) {
+ List<BrokenReferencesModel.ProblemReference> fatals = new ArrayList<>();
+ for (BrokenReferencesModel.ProblemReference ref : model.projectProblems(project, includeSeen)) {
+ if (seen.contains(ref.problem) || ref.resolved) {
+ continue;
+ }
+ if (ref.seen && !includeSeen) {
+ continue;
+ }
+ ProjectProblemsProvider.ProjectProblem pp = ref.problem;
+ if (!pp.isResolvable()) {
+ fatals.add(ref);
+ } else {
+ probs.add(ref);
+ }
+ }
+ return fatals;
+ }
+
+ void finishThis() {
+ controller.finishProject(this);
+ if (cancelled != null) {
+ if (completion.completeExceptionally(cancelled)) {
+ return;
+ }
+ }
+ completion.complete(allProcessed);
+ }
+
+ final class Ctx {
+ final int attempt;
+ final boolean autoResolve;
+
+ public Ctx(boolean autoResolve) {
+ this(runAttempt, autoResolve);
+ }
+
+ public Ctx(int attempt, boolean autoResolve) {
+ this.attempt = attempt;
+ this.autoResolve = autoResolve;
+ }
+
+ boolean valid() {
+ synchronized (ProjectAlertPresenter.this) {
+ return this.attempt == runAttempt && controller.isActivePresenter(ProjectAlertPresenter.this);
+ }
+ }
+
+ Ctx autoresolve(boolean r) {
+ return new Ctx(attempt, r);
+ }
+ }
+
+ /**
+ * Hnadles display of errors limited to some number. The value is cleared when all
+ * the errors were presented + confirmed.
+ */
+ // @GuardedBy(this)
+ ErrorQueue errorQueue = null;
+
+ /**
+ * Displays fatal errors, at most {@link #MAX_PRESENTED_ERRORS} at a time. When an error message is closed,
+ * displays a new one, if it is in the queue. Completes the {@link #allDone} Future after all messages
+ * have been confirmed or were cancelled.
+ */
+ class ErrorQueue {
+ final Ctx ctx;
+
+ /**
+ * All messages to be processed.
+ */
+ final List<BrokenReferencesModel.ProblemReference> toProcess = new ArrayList<>();
+
+ /**
+ * Messages already processed.
+ */
+ final Set<BrokenReferencesModel.ProblemReference> processed = new HashSet<>();
+
+ /**
+ * Future which will be completed after all the messages are shown or the process is cancelled.
+ */
+ CompletableFuture allDone = new CompletableFuture();
+
+ /**
+ * Becomes true once the process displays the 1st dialog.
+ */
+ boolean started;
+
+ /**
+ * The number of messages currently displayed, should be at most {@link #MAX_PRESENTED_ERRORS}.
+ */
+ int fatalErrorsOnScreen;
+
+ /**
+ * Non-null if some of the messages complete with an exception.
+ */
+ Throwable error;
+
+
+ public ErrorQueue(Ctx ctx) {
+ this.ctx = ctx;
+ }
+
+ public void runOrDelay(BrokenReferencesModel.ProblemReference p) {
+ synchronized (this) {
+ if (fatalErrorsOnScreen >= MAX_PRESENTED_ERRORS) {
+ // delay:
+ return;
+ }
+ fatalErrorsOnScreen++;
+ }
+ displayError(p);
+ }
+
+ CompletableFuture moreErrors(List<BrokenReferencesModel.ProblemReference> newErrors) {
+ synchronized (this) {
+ if (started && processed.containsAll(toProcess)) {
+ return null;
+ }
+ started = true;
+ toProcess.addAll(newErrors);
+ }
+ for (BrokenReferencesModel.ProblemReference r : newErrors) {
+ runOrDelay(r);
+ }
+ return allDone;
+ }
+
+ void terminate(boolean all) {
+ Throwable exception;
+
+ synchronized (this) {
+ for (BrokenReferencesModel.ProblemReference ref : toProcess) {
+ if (!all && seen.contains(ref.problem)) {
+ continue;
+ }
+ processed.add(ref);
+ }
+ if (!processed.containsAll(toProcess)) {
+ return;
+ }
+ synchronized (ProjectAlertPresenter.this) {
+ if (this == errorQueue) {
+ errorQueue = null;
+ }
+ }
+ exception = error;
+ }
+ // complete the future outside the lock.
+ if (exception != null) {
+ allDone.completeExceptionally(exception);
+ } else {
+ allDone.complete(null);
+ }
+ }
+
+ public void displayError(BrokenReferencesModel.ProblemReference toPresent) {
+ ProjectProblemsProvider.ProjectProblem p = toPresent.problem;
+ seen.add(p);
+ LOG.log(Level.FINE, "Reporting fatal {0}", p.getDisplayName());
+ int type;
+ switch (p.getSeverity()) {
+ default:
+ case ERROR:
+ type = NotifyDescriptor.ERROR_MESSAGE;
+ break;
+ case WARNING:
+ type = NotifyDescriptor.WARNING_MESSAGE;
+ break;
+ }
+ // hack: the LSP protocol does not support title. Until fixed, or implemented through a custom message,
+ // embed the title into description:
+ String title = Bundle.ProjectProblem_Title(projectName, p.getDisplayName());
+ NotifyDescriptor msg = new NotifyDescriptor(title + ": " + p.getDescription(), title, NotifyDescriptor.DEFAULT_OPTION, type, new Object[]{NotifyDescriptor.OK_OPTION}, null);
+
+ // Note: the number of 'fatal' dialogs displayed at the same time is limited by the RP throughput. Dialog API does not support CompletableFuture<> interface
+ // so threads may dangle.
+ CompletableFuture<Integer> running = DialogDisplayer.getDefault().notifyFuture(msg).handle((n, e) -> {
+ Optional<BrokenReferencesModel.ProblemReference> o;
+ int result = 0;
+ synchronized (this) {
+ processed.add(toPresent);
+ if (!ctx.valid() || cancelled != null) {
+ // will complete the 'running' future.
+ return 2;
+ }
+ o = toProcess.stream().filter((a) ->
+ !seen.contains(a.problem)).findFirst();
+ // o.isPresent(), will replace the current error with a new one, no change to the counter.
+ if (!o.isPresent()) {
+ result = 1;
+ if (fatalErrorsOnScreen > 0) {
+ fatalErrorsOnScreen--;
+ }
+ }
+ }
+ if (o.isPresent()) {
+ // just run outside the synchronized block - display another error.
+ displayError(o.get());
+ }
+ return result;
+ });
+ // chaining instead of try - finally ;)
+ running.whenComplete((t, ex) -> {
+ if (t > 0) {
+ terminate(t > 1);
+ } else {
+ synchronized (ProjectAlertPresenter.this) {
+ // attempt to postpone the task after user's reaction.
+ if (wakeUpTask != null) {
+ LOG.log(Level.FINER, "Trying to postpone wakeup for {0}ms", WAKEUP_DELAY);
+ wakeUpTask.schedule(ERRORS_WAKEUP_DELAY);
+ }
+ }
+ }
+ });
+ }
+ }
+
+ @NbBundle.Messages(value = {"# {0} - project name", "# {1} - issue title", "ProjectProblems_Fixable_Title=Project {0}: {1}"})
+ void processOneRound(Ctx ctx) {
+ if (!RP.isRequestProcessorThread()) {
+ RP.post(() -> processOneRound(ctx));
+ return;
+ }
+
+ assert RP.isRequestProcessorThread();
+
+ model.refresh(false);
+ List<BrokenReferencesModel.ProblemReference> probs = new ArrayList<>();
+ List<BrokenReferencesModel.ProblemReference> fatals = findProblems(probs);
+ synchronized (this) {
+ if (!ctx.valid()) {
+ return;
+ }
+ wakeUpTask = null;
+ snapshot = new HashSet<>();
+ snapshot.addAll(probs);
+ snapshot.addAll(fatals);
+ }
+ if (probs.isEmpty() && fatals.isEmpty()) {
+ LOG.log(Level.FINE, "Project {0} clear, finishing", projectName);
+ finishThis();
+ return;
+ }
+ if (cancelled != null) {
+ finishThis();
+ return;
+ }
+
+ if (!fatals.isEmpty()) {
+ ErrorQueue activeBatch;
+ CompletableFuture f = null;
+ boolean newBatch;
+
+ // loop in case the batch just terminates & clears the reference.
+ do {
+ newBatch = false;
+ synchronized (this) {
+ activeBatch = errorQueue;
+ if (activeBatch == null) {
+ errorQueue = activeBatch = new ErrorQueue(ctx);
+ newBatch = true;
+ }
+ }
+ f = activeBatch.moreErrors(fatals);
+
+ synchronized (this) {
+ if (f == null && errorQueue == activeBatch) {
+ errorQueue = null;
+ }
+ }
+ } while (f == null);
+
+ if (newBatch) {
+ synchronized (this) {
+ LOG.log(Level.FINE, "Waiting for {0} items, scheduling wakeup task", fatals.size());
+ wakeUpTask = RP.create(() -> resumeAfterTimeout(ctx));
+ wakeUpTask.schedule(ERRORS_WAKEUP_DELAY);
+ }
+ }
+ f.thenAccept(r -> continueResetPending(ctx));
+ return;
+ }
+
+ if (probs.isEmpty()) {
+ finishThis();
+ return;
+ }
+ BrokenReferencesModel.ProblemReference ref = probs.iterator().next();
+
+ BiFunction<ProjectProblemsProvider.Result, Throwable, Void> handlerFn = (ProjectProblemsProvider.Result r, Throwable e) -> {
+ if (!ctx.valid()) {
+ return null;
+ }
+ if (e instanceof CompletionException) {
+ e = e.getCause();
+ }
+ if (e instanceof CancellationException) {
+ // do not proceeed further
+ finishIfNoMoreErrors(ctx, probs);
+ return null;
+ }
+ if (r != null) {
+ processOneRound(ctx.autoresolve(true));
+ } else {
+ processOneRound(ctx.autoresolve(false));
+ }
+ return null;
+ };
+ if (ctx.autoResolve) {
+ postResolveProblem(ctx, ref).handle(handlerFn);
+ } else {
+ seen.add(ref.problem);
+ // present a question
+
+ if (cancelled != null) {
+ finishThis();
+ return;
+ }
+
+ RequestProcessor.Task t;
+ synchronized (this) {
+ if (wakeUpTask == null) {
+ t = wakeUpTask = RP.post(() -> resumeAfterTimeout(ctx), QUESTION_WAKEUP_DELAY);
+ } else {
+ t = null;
+ }
+ }
+
+ String title = Bundle.ProjectProblems_Fixable_Title(projectName, ref.problem.getDisplayName());
+ String msg = ref.problem.getDescription();
+ if (probs.size() > 1) {
+ msg = Bundle.ProjectProblems_Additional(probs.size() - 1, msg);
+ }
+ Object[] options = probs.size() > 1 ? new Object[]{NotifyDescriptor.OK_OPTION, restOption, NotifyDescriptor.CANCEL_OPTION} : new Object[]{NotifyDescriptor.OK_OPTION, NotifyDescriptor.CANCEL_OPTION};
+ NotifyDescriptor desc = new NotifyDescriptor(title + ": " + msg, title, NotifyDescriptor.DEFAULT_OPTION, NotifyDescriptor.QUESTION_MESSAGE, options, null);
+ // FIXME - Bad bad - this should be done in a separate RP as it may block in the client.
+ DialogDisplayer.getDefault().notifyFuture(desc).whenComplete((d, ex) -> {
+ synchronized (this) {
+ if (t != null && wakeUpTask == t) {
+ wakeUpTask.cancel();
+ wakeUpTask = null;
+ }
+ }
+ if (cancelled != null) {
+ finishThis();
+ return;
+ }
+ Object res = d.getValue();
+ if (restOption.equals(res)) {
+ synchronized (this) {
+ if (!ctx.valid()) {
+ // resolve just that one issue, the rest will be solved by other questions.
+ postResolveProblem(ctx.autoresolve(false), ref).handle(handlerFn);
+ return;
+ } else {
+ seen.remove(ref.problem);
+ }
+ }
+ processOneRound(ctx.autoresolve(true));
+ } else if (res != NotifyDescriptor.OK_OPTION) {
+ handlerFn.apply(null, null);
+ } else {
+ postResolveProblem(ctx.autoresolve(false), ref).handle(handlerFn);
+ }
+ }).exceptionally(x -> {
+ if (x instanceof CompletionException) {
+ x = x.getCause();
+ }
+ if (x instanceof CancellationException) {
+ finishIfNoMoreErrors(ctx, probs);
+ }
+ return null;
+ });
+ }
+ }
+
+ /**
+ * Finishes the process, if no more errors is reported in the meantime. Will dismiss all questions
+ * collected so far.
+ * @param dismiss questions to dismiss.
+ */
+ private void finishIfNoMoreErrors(Ctx ctx, Collection<BrokenReferencesModel.ProblemReference> dismiss) {
+ synchronized (this) {
+ allProcessed &= dismiss.isEmpty();
+ seen.addAll(dismiss.stream().map(r -> r.problem).collect(Collectors.toList()));
+ }
+ List<BrokenReferencesModel.ProblemReference> probs = new ArrayList<>();
+ List<BrokenReferencesModel.ProblemReference> fatals = findProblems(probs);
+ if (!fatals.isEmpty()) {
+ processOneRound(ctx);
+ } else {
+ finishThis();
+ }
+ }
+
+ /**
+ * Schedules problem resolution into the common RequestProcessor. The returned CompletableFuture
+ * will complete after the fix is done and potentially after the user answers question after fix reports
+ * some warning or error. {@code non-null} is returned to indicate that no further questions are to be asked.
+ * If the user cancels the dialog, [@link CancellationException} will be recorded in the Future.
+ * @param ref problem to resolve
+ * @return future with null/non-null result or cancellation
+ */
+ @NbBundle.Messages(value = {"ProjectProblems_RestOption=&Fix All", "ProjectProblems_DetailsOption=Display &Details"})
+ private CompletableFuture<ProjectProblemsProvider.Result> postResolveProblem(Ctx ctx, BrokenReferencesModel.ProblemReference ref) {
+ CompletableFuture<ProjectProblemsProvider.Result> f = // convert the exception to an error message to the user.
+ // do not bother
+ CompletableFuture.supplyAsync(() -> {
+ try {
+ seen.add(ref.problem);
+ return ref.problem.resolve().get();
+ } catch (ExecutionException ex) {
+ // convert the exception to an error message to the user.
+ return ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.UNRESOLVED, ex.getCause().getLocalizedMessage());
+ } catch (InterruptedException ex) {
+ // do not bother
+ return null;
+ }
+ }, RESOLVE_RP).thenApply(r -> {
+ if (r.isResolved()) {
+ if (r.getMessage() != null) {
+ StatusDisplayer.getDefault().setStatusText(r.getMessage());
+ }
+ return ctx.autoResolve ? r : null;
+ }
+ List<BrokenReferencesModel.ProblemReference> probs = new ArrayList<>();
+ List<BrokenReferencesModel.ProblemReference> fatals = findProblems(probs);
+ NotifyDescriptor desc = createNotifyDescriptor(ref.problem, r, probs.size());
+ if (fatals.isEmpty() && !probs.isEmpty()) {
+ desc.setOptions(new Object[]{restOption, detailOption, NotifyDescriptor.CANCEL_OPTION});
+ desc.setValue(detailOption);
+ }
+ Object v = DialogDisplayer.getDefault().notify(desc);
+ if (cancelled != null) {
+ throw new CancellationException();
+ }
+ if (v == NotifyDescriptor.CANCEL_OPTION || v == null) {
+ throw new CancellationException();
+ } else if (restOption.equals(v)) {
+ return r;
+ } else {
+ return null;
+ }
+ });
+ return f;
+ }
+
+ private NotifyDescriptor createNotifyDescriptor(ProjectProblemsProvider.ProjectProblem pp, ProjectProblemsProvider.Result r, int probs) {
+ String title;
+ String msg;
+ int type;
+ if (r.getStatus() == ProjectProblemsProvider.Status.UNRESOLVED) {
+ title = Bundle.ProjectProblems_Resolved_Error(projectName);
+ msg = Bundle.ProjectProblems_Resolved_ErrorMessage1(pp.getDisplayName(), r.getMessage());
+ type = NotifyDescriptor.ERROR_MESSAGE;
+ } else {
+ title = Bundle.ProjectProblems_Resolved_Warning(projectName);
+ msg = Bundle.ProjectProblems_Resolved_WarningMessage1(pp.getDisplayName(), r.getMessage());
+ type = NotifyDescriptor.WARNING_MESSAGE;
+ }
+ if (probs > 0) {
+ msg = Bundle.ProjectProblems_Additional(probs, msg);
+ }
+ // hack: the LSP protocol does not support title. Until fixed, or implemented through a custom message,
+ // embed the title into description:
+ msg = title + ": " + msg; // NOI18N
+ NotifyDescriptor desc = new NotifyDescriptor(msg, title, NotifyDescriptor.DEFAULT_OPTION, type, new Object[]{NotifyDescriptor.OK_OPTION}, null);
+ return desc;
+ }
+
+ private void continueResetPending(Ctx ctx) {
+ LOG.log(Level.FINE, "All dialogs confirmed");
+ synchronized (this) {
+ if (!ctx.valid()) {
+ return;
+ }
+ if (wakeUpTask != null && !wakeUpTask.isFinished() && !wakeUpTask.cancel()) {
+ return;
+ }
+ }
+ processOneRound(ctx);
+ }
+
+ interface Env {
+ public void finishProject(ProjectAlertPresenter p);
+ public boolean isActivePresenter(ProjectAlertPresenter p);
+ }
+}
diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/AbstractLspBrokenReferences.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/AbstractLspBrokenReferences.java
new file mode 100644
index 0000000..f2ffe86
--- /dev/null
+++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/AbstractLspBrokenReferences.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.java.lsp.server.ui;
+
+import java.util.concurrent.CompletableFuture;
+import org.netbeans.api.project.Project;
+import org.netbeans.modules.java.lsp.server.project.BrokenReferencesImpl;
+import org.netbeans.spi.project.ui.ProjectProblemsImplementation;
+
+/**
+ * Implementation of {@link ProjectProblemsImplementation} implemented using
+ * dialogs over LSP.
+ * @since 1.19
+ * @author sdedic
+ */
+public abstract class AbstractLspBrokenReferences implements ProjectProblemsImplementation {
+ private final BrokenReferencesImpl delegate;
+
+ protected AbstractLspBrokenReferences() {
+ delegate = new BrokenReferencesImpl();
+ }
+
+ @Override
+ public CompletableFuture<Void> showAlert(Project project) {
+ return delegate.showAlert(project);
+ }
+
+ @Override
+ public CompletableFuture<Void> showCustomizer(Project project) {
+ return delegate.showCustomizer(project);
+ }
+}
diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/project/LspBrokenReferencesImplTest.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/project/LspBrokenReferencesImplTest.java
new file mode 100644
index 0000000..cb18998
--- /dev/null
+++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/project/LspBrokenReferencesImplTest.java
@@ -0,0 +1,1287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.netbeans.modules.java.lsp.server.project;
+
+import java.awt.Dialog;
+import java.beans.PropertyChangeListener;
+import java.beans.PropertyChangeSupport;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import static junit.framework.TestCase.fail;
+import org.netbeans.api.project.Project;
+import org.netbeans.api.project.ProjectManager;
+import org.netbeans.api.project.ui.OpenProjects;
+import org.netbeans.junit.NbTestCase;
+import org.netbeans.modules.project.ui.problems.BrokenProjectNotifier;
+import org.netbeans.spi.project.LookupProvider;
+import org.netbeans.spi.project.ui.ProjectProblemResolver;
+import org.netbeans.spi.project.ui.ProjectProblemsProvider;
+import org.openide.DialogDescriptor;
+import org.openide.DialogDisplayer;
+import org.openide.NotifyDescriptor;
+import org.openide.filesystems.FileObject;
+import org.openide.filesystems.FileUtil;
+import org.openide.util.Lookup;
+import org.openide.util.lookup.Lookups;
+import org.openide.util.test.MockLookup;
+import org.netbeans.api.annotations.common.NonNull;
+import org.openide.util.RequestProcessor;
+
+/**
+ *
+ * @author sdedic
+ */
+public class LspBrokenReferencesImplTest extends NbTestCase {
+
+ public LspBrokenReferencesImplTest(String name) {
+ super(name);
+ }
+
+
+ @Override
+ protected void setUp() throws Exception {
+ reporter = new TestProblemReporter();
+ super.setUp();
+
+ clearWorkDir();
+ FileObject r = FileUtil.getConfigRoot();
+ // Hack: create .instance registration in the config area. We do not have project lookup mocking service - yet.
+ FileObject dir = FileUtil.createFolder(r, "Projects/org-netbeans-modules-maven/Lookup");
+ dir.getFileSystem().runAtomicAction(() -> {
+ FileObject regFile = dir.createData("test-problem-provider.instance");
+ // works with the in-memory filesystem. Will not work if module system gets loaded.
+ regFile.setAttribute("instanceCreate", forProjectServices());
+ regFile.setAttribute("position", 10000);
+ });
+ BrokenProjectNotifier.getInstnace().start();
+
+ MockLookup.setLayersAndInstances(displayer, tested);
+
+ // causes project info to be loaded synchronously
+ System.setProperty("test.load.sync", Boolean.TRUE.toString());
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ BrokenProjectNotifier.getInstnace().stop();
+ // Hack: create .instance registration in the config area. We do not have project lookup mocking service - yet.
+ FileObject f = FileUtil.getConfigFile("Projects/org-netbeans-modules-maven/Lookup/test-problem-provider.instance");
+ if (f != null) {
+ f.delete();
+ }
+ if (displayer != null) {
+ for (Response r : displayer.expectedResponses) {
+ if (r.responseLock != null) {
+ r.responseLock.countDown();
+ }
+ }
+ Response r = displayer.currentResponse;
+ if (r != null && r.responseLock != null) {
+ r.responseLock.countDown();
+ }
+ while (displayer.currentResponse != null) {
+ synchronized (displayer) {
+ displayer.notifyAll();
+ }
+ }
+ }
+ // reset custom thresholds
+ ProjectAlertPresenter.MAX_PRESENTED_ERRORS = 10;
+ ProjectAlertPresenter.ERRORS_WAKEUP_DELAY = 2 * 60 * 1000;
+ ProjectAlertPresenter.QUESTION_WAKEUP_DELAY = 5 * 60 * 1000;
+ super.tearDown();
+ }
+
+ private TestProblemReporter reporter = new TestProblemReporter();
+
+ private FileObject createProject(FileObject dir, String name, String... lines) throws IOException {
+ final FileObject[] ret = new FileObject[1];
+ dir.getFileSystem().runAtomicAction(() -> {
+ FileObject projDir = name == null ? dir : dir.createFolder(name);
+ ret[0] = projDir;
+ try (OutputStream os = projDir.createAndOpen("pom.xml");
+ OutputStreamWriter wr = new OutputStreamWriter(os);
+ PrintWriter pw = new PrintWriter(wr)) {
+ for (String l : lines) {
+ pw.println(l);
+ }
+ }
+ });
+ return ret[0];
+ }
+
+ final DialogController displayer = new DialogController();
+ final TestedImpl tested = new TestedImpl();
+
+ final AtomicBoolean resolveCalled = new AtomicBoolean(false);
+ final AtomicReference<ProjectProblemsProvider.ProjectProblem> ref = new AtomicReference();
+
+ /**
+ * Fills in 'ref'
+ */
+ private void createFixableError() {
+ ProjectProblemsProvider.ProjectProblem pp = ProjectProblemsProvider.ProjectProblem.createError(
+ "Test",
+ "Fixable-error",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.remove(ref.get());
+ resolveCalled.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref.set(pp);
+ reporter.reportProblems.add(pp);
+ }
+
+ private FileObject createSimpleProject() throws IOException {
+
+ File wdBase = getWorkDir();
+ FileObject wdFile = FileUtil.toFileObject(wdBase);
+
+ projectServices.put(wdBase.toPath(), Arrays.asList(reporter));
+ FileObject pdir = createProject(wdFile, null,
+ "<project xmlns='http://maven.apache.org/POM/4.0.0'>",
+ " <modelVersion>4.0.0</modelVersion>",
+ " <artifactId>m</artifactId>" +
+ " <groupId>g</groupId>" +
+ " <version>1.0-SNAPSHOT</version>" +
+ " <name>Test Project</name>" +
+ "</project>"
+ );
+ return pdir;
+ }
+
+ /**
+ * Checks that a fixable error appears to the user, and can be resolved.
+ */
+ public void testFixableReport() throws Exception {
+ FileObject pdir = createSimpleProject();
+
+ Semaphore block = new Semaphore(0);
+
+ displayer.expectedResponses.add(new Response("Test Project", "Fixable-error", NotifyDescriptor.OK_OPTION));
+ displayer.responsePermits.drainPermits();
+
+ createFixableError();
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+
+ tested.presenterNotify.put(prj.getProjectDirectory(), block);
+
+ assertNotNull(prj);
+ ProjectProblemsProvider prov = prj.getLookup().lookup(ProjectProblemsProvider.class);
+ assertNotNull(prov);
+ assertFalse(prov.getProblems().isEmpty());
+
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(block.tryAcquire(10000, TimeUnit.SECONDS));
+ ProjectAlertPresenter presenter = tested.getPresenter(prj);
+
+ // The displayer execution is now blocked, so the presenter remains.
+ assertNotNull(presenter);
+
+ // release the displayer -> let the presenter to finish.
+ displayer.responsePermits.release();
+
+ assertTrue("Fixes accepted", presenter.getCompletion().get());
+ assertNull("Presenter has finished", tested.getPresenter(prj));
+
+ Collection<? extends ProjectProblemsProvider.ProjectProblem> probs = prov.getProblems();
+ assertTrue(probs.isEmpty());
+ }
+
+ /**
+ * Check that a project with 'hard' errors will issue the messages and then
+ * allow the user to fix the fixable ones.
+ * @throws Exception
+ */
+ public void testHardErrorComesBeforeFixable() throws Exception {
+ FileObject pdir = createSimpleProject();
+
+ // deliberately in the reverse order
+ createFixableError();
+
+ ProjectProblemsProvider.ProjectProblem unfixable = ProjectProblemsProvider.ProjectProblem.createError(
+ "Unfixable",
+ "Bye-bye",
+ null);
+ reporter.reportProblems.add(unfixable);
+ reporter.response.release(100);
+
+ // response to the fixable error
+ Response r = new Response("Test Project", "Fixable-error", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ // response to the unfixable error
+ displayer.expectedResponses.add(new Response("Test Project", "Unfixable", NotifyDescriptor.OK_OPTION));
+
+ // at the time the response is answered
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(r.responseSelected.tryAcquire(100, TimeUnit.SECONDS));
+ assertSame(r, displayer.currentResponse);
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+
+ // check that the notified list already contains the 'error' message
+ assertTrue(displayer.notifyNow.stream().anyMatch(nd -> nd.getMessage().toString().contains("Bye-bye")));
+
+ r.responseLock.countDown();
+
+ assertTrue(p.getCompletion().get());
+ }
+
+ private List<Response> responses;
+
+ private Semaphore responseCount;
+
+ private FileObject pdir;
+
+ /**
+ * Fills in pdir, responseCount, responses.
+ */
+ private void setupProjectWithUnfixableErrors() throws Exception {
+ pdir = createSimpleProject();
+
+ List<ProjectProblemsProvider.ProjectProblem> unfixables = new ArrayList<>();
+ responses = new ArrayList<>();
+ for (int i = 0; i < 5; i++) {
+ unfixables.add(ProjectProblemsProvider.ProjectProblem.createError(
+ "Unfixable",
+ "Unfixable" + i,
+ null));
+ }
+ reporter.reportProblems.addAll(unfixables);
+ reporter.response.release(100);
+
+ responseCount = new Semaphore(0);
+ for (int i = 0; i < 5; i++) {
+ Response r = new Response("Test Project", "Unfixable" + i, NotifyDescriptor.OK_OPTION);
+ r.responseSelected = responseCount;
+ r.responseLock = new CountDownLatch(1);
+ responses.add(r);
+ }
+ displayer.expectedResponses.addAll(responses);
+
+ }
+
+ /**
+ * Checks that if there are too many errors (more than permitted threshold), they are still
+ * displayed - after user confirms some of the earlier ones, the rest will appear.
+ * @throws Exception
+ */
+ public void testTooManyErrorsStillDisplayed() throws Exception {
+ ProjectAlertPresenter.MAX_PRESENTED_ERRORS = 2;
+ setupProjectWithUnfixableErrors();
+ // at the time the response is answered
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ responses.get(4).responseLock.countDown();
+ responses.get(3).responseLock.countDown();
+ responses.get(2).responseLock.countDown();
+
+ // wait till >= two responses are selected
+ assertTrue(responseCount.tryAcquire(2, 10, TimeUnit.SECONDS));
+
+ assertEquals("Two notify Descriptors received, others blocked by RP", 2, displayer.notifyNow.size());
+ assertEquals("None response answered yet", 0, displayer.answeredResponses.size());
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+ assertFalse(p.getCompletion().isDone());
+
+ // release three problems
+ responses.get(0).responseLock.countDown();
+ responses.get(1).responseLock.countDown();
+
+ // the presenter should now be able to finish
+ assertTrue("The presenter is able to finish", p.getCompletion().get());
+ }
+
+ /**
+ * Checks that if there are errors displayed, they will eventually time out and
+ * the process will terminate.
+ * @throws Exception
+ */
+ public void testIgnoredErrorsTimeout() throws Exception {
+ // reduce the timeout to a manageable time:
+ ProjectAlertPresenter.ERRORS_WAKEUP_DELAY = 1000;
+ setupProjectWithUnfixableErrors();
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ // let the notifications to be fired.
+ assertTrue(responseCount.tryAcquire(3, 10, TimeUnit.SECONDS));
+ // wait some more
+ Thread.sleep(250);
+
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+ assertFalse("Presenter is still running", p.getCompletion().isDone());
+
+ assertTrue("The presenter finishes because of timeout", p.getCompletion().get());
+ }
+
+ /**
+ * Checks that a fixable error *is* presented despite many hard errors, after a timeout.
+ * @throws Exception
+ */
+ public void testQuestionPresentedWithManyErrors() throws Exception {
+ // reduce the timeout to a manageable time:
+ ProjectAlertPresenter.ERRORS_WAKEUP_DELAY = 1000;
+ setupProjectWithUnfixableErrors();
+ createFixableError();
+
+ Response r = new Response("Test Project", "Fixable-error", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(r);
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue("Fixable error must be reported after timeout", r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ }
+
+ AtomicReference<ProjectProblemsProvider.ProjectProblem> ref2 = new AtomicReference<>();
+ AtomicBoolean resolveCalled2 = new AtomicBoolean();
+
+ private void setupTwoFixables() throws Exception {
+ pdir = createSimpleProject();
+ ProjectProblemsProvider.ProjectProblem pp = ProjectProblemsProvider.ProjectProblem.createError(
+ "Test",
+ "Fixable1",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.remove(ref.get());
+ resolveCalled.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref.set(pp);
+ reporter.reportProblems.add(pp);
+
+ ProjectProblemsProvider.ProjectProblem pp2 = ProjectProblemsProvider.ProjectProblem.createError(
+ "Test",
+ "Fixable2",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.remove(ref2.get());
+ resolveCalled2.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref2.set(pp);
+ reporter.reportProblems.add(pp2);
+ }
+
+ public void testOneQuestionFollowsOther() throws Exception {
+ setupTwoFixables();
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ r2.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r2);
+
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ // wait for the 1st response to be selected
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ // the presenter is active, and the 2nd question is NOT yet even asked
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+ assertFalse(p.getCompletion().isDone());
+ assertTrue(displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable1")));
+ assertTrue(displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2")));
+
+ // release the response
+ r.responseLock.countDown();
+
+ // 2nd response is presented - at this time 1st problem must be already resolved.
+ assertTrue(r2.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ assertTrue(resolveCalled.get());
+
+ r2.responseLock.countDown();
+ assertTrue("The presenter is able to finish", p.getCompletion().get());
+ assertTrue(resolveCalled2.get());
+ }
+
+ public void testOneResolveOneNot() throws Exception {
+ setupTwoFixables();
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ r2.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r2);
+
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ // wait for the 1st response to be selected
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ // the presenter is active, and the 2nd question is NOT yet even asked
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+ assertFalse(p.getCompletion().isDone());
+ assertTrue(displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable1")));
+ assertTrue(displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2")));
+
+ // release the response
+ r.responseLock.countDown();
+
+ // 2nd response is presented - at this time 1st problem must be already resolved.
+ assertTrue(r2.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ assertTrue(resolveCalled.get());
+
+ r2.responseLock.countDown();
+ assertTrue("The presenter is able to finish", p.getCompletion().get());
+ assertTrue(resolveCalled2.get());
+ }
+
+ public void testRejectOneAcceptOther() throws Exception {
+ setupTwoFixables();
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.NO_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ r2.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r2);
+
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ // wait for the 1st response to be selected
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ // the presenter is active, and the 2nd question is NOT yet even asked
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+ assertFalse(p.getCompletion().isDone());
+ assertTrue(displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable1")));
+ /*
+ if (!displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2"))) {
+ System.err.println("");
+ }
+ */
+ assertTrue(displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2")));
+
+ // release the response
+ r.responseLock.countDown();
+
+ // 2nd response is presented - at this time 1st problem must be already resolved.
+ assertTrue(r2.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ // the rejected fix did not happen
+ assertFalse(resolveCalled.get());
+ // the 2nd question presented
+ assertTrue(displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable2")));
+
+ r2.responseLock.countDown();
+ assertTrue("The presenter is able to finish", p.getCompletion().get());
+ assertTrue(resolveCalled2.get());
+ }
+
+ public void testRestResolvesWithoutAsking() throws Exception {
+ setupTwoFixables();
+ Response r = new Response("Test Project", "Fixable1", Bundle.ProjectProblems_RestOption());
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(r2);
+
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ // wait for the 1st response to be selected
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ // the presenter is active, and the 2nd question is NOT yet even asked
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+ assertFalse(p.getCompletion().isDone());
+ assertTrue("1st dialog shown", displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable1")));
+ assertTrue("2nd dialog not shown yet", displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2")));
+
+ // release the response
+ r.responseLock.countDown();
+ assertTrue("The presenter is able to finish", p.getCompletion().get());
+
+ assertTrue("2nd dialog never presented", displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2")));
+
+ // both fixes happened
+ assertTrue("Fix 1 applied", resolveCalled.get());
+ assertTrue("Fix 2 applied", resolveCalled2.get());
+ }
+
+ /**
+ * After problem fix fails, a report with that error must be displayed.
+ */
+ public void testProblemFixFails() throws Exception {
+ pdir = createSimpleProject();
+ ProjectProblemsProvider.ProjectProblem pp = ProjectProblemsProvider.ProjectProblem.createError(
+ "TestFixable1",
+ "Fixable1",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ resolveCalled.set(true);
+ return CompletableFuture.completedFuture(
+ ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.UNRESOLVED, "NotResolved1")
+ );
+ }
+ }
+ );
+ ref.set(pp);
+ reporter.reportProblems.add(pp);
+
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response errResp = new Response("Test Project", "failed: NotResolved1", NotifyDescriptor.OK_OPTION);
+ errResp.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(errResp);
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+
+ r.responseLock.countDown();
+
+ // wait for the 1st response to be selected
+ assertTrue("Fix response selected", errResp.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled.get());
+
+ Optional<NotifyDescriptor> nd = displayer.notifyNow.stream().filter(d -> d.getMessage().toString().contains("failed: NotResolved1")).findAny();
+ assertTrue("Error dialog shown", nd.isPresent());
+ assertEquals("Just OK is present.", 1, nd.get().getOptions().length);
+ }
+
+ private void setupFailAndOkFixables() throws Exception {
+ pdir = createSimpleProject();
+ ProjectProblemsProvider.ProjectProblem pp = ProjectProblemsProvider.ProjectProblem.createError(
+ "TestFixable1",
+ "Fixable1",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ resolveCalled.set(true);
+ return CompletableFuture.completedFuture(
+ ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.UNRESOLVED, "NotResolved1")
+ );
+ }
+ }
+ );
+
+ ProjectProblemsProvider.ProjectProblem pp2 = ProjectProblemsProvider.ProjectProblem.createError(
+ "TestFixable2",
+ "Fixable2",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.add(ref2.get());
+ resolveCalled2.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref.set(pp);
+ ref2.set(pp2);
+ reporter.reportProblems.add(pp);
+ reporter.reportProblems.add(pp2);
+
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response errResp = new Response("Test Project", "failed: NotResolved1", NotifyDescriptor.OK_OPTION);
+ errResp.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(errResp);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(r2);
+ }
+
+
+ /**
+ * 1st question confirmed, resolution fails. Error message appears, user clicks details. Next question appears.
+ * @throws Exception
+ */
+ public void testFirstProblemFailsExecuteNext() throws Exception {
+ setupFailAndOkFixables();
+ Response r = displayer.expectedResponses.get(0);
+ Response errResp = displayer.expectedResponses.get(1);
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+
+ r.responseLock.countDown();
+
+ // wait for the 1st response to be selected
+ assertTrue("Fix response selected", errResp.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled.get());
+
+ Optional<NotifyDescriptor> nd = displayer.notifyNow.stream().filter(d -> d.getMessage().toString().contains("failed: NotResolved1")).findAny();
+ assertTrue("Error dialog shown", nd.isPresent());
+ assertEquals("Fix all, Details, Cancel should be present.", 3, nd.get().getOptions().length);
+ assertTrue(resolveCalled2.get());
+ assertTrue("Second question displayed.",
+ displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable2")));
+ }
+
+ /**
+ * After 1st fix fails, "Fix all" is pressed. The next dialog is not displayed, but the
+ * fix is applied.
+ * @throws Exception
+ */
+ public void testFirstProblemFailsExecuteRest() throws Exception {
+ setupFailAndOkFixables();
+ Response r = displayer.expectedResponses.get(0);
+ Response errResp = displayer.expectedResponses.get(1);
+ // let the rest of fixes happen
+ errResp.response = Bundle.ProjectProblems_RestOption();
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+
+ r.responseLock.countDown();
+
+ // wait for the 1st response to be selected
+ assertTrue("Fix response selected", errResp.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled.get());
+
+ Optional<NotifyDescriptor> nd = displayer.notifyNow.stream().filter(d -> d.getMessage().toString().contains("failed: NotResolved1")).findAny();
+ assertTrue("Error dialog shown", nd.isPresent());
+ assertEquals("Fix all, Details, Cancel should be present.", 3, nd.get().getOptions().length);
+ assertTrue(resolveCalled2.get());
+ assertTrue("Second question NOT displayed.",
+ displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("Fixable2")));
+ }
+
+ /**
+ * The process is cancelled after 1st failure.
+ * @throws Exception
+ */
+ public void testFirstProblemFailsCancel() throws Exception {
+ setupFailAndOkFixables();
+ Response r = displayer.expectedResponses.get(0);
+ Response errResp = displayer.expectedResponses.get(1);
+
+ errResp.response = NotifyDescriptor.CANCEL_OPTION;
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ ProjectAlertPresenter p = tested.getPresenter(prj);
+ assertNotNull(p);
+
+ r.responseLock.countDown();
+
+ // wait for the 1st response to be selected
+ assertTrue("Fix response selected", errResp.responseSelected.tryAcquire(100, TimeUnit.SECONDS));
+
+ assertFalse("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled.get());
+
+ Optional<NotifyDescriptor> nd = displayer.notifyNow.stream().filter(d -> d.getMessage().toString().contains("failed: NotResolved1")).findAny();
+ assertTrue("Error dialog shown", nd.isPresent());
+ assertEquals("Fix all, Details, Cancel should be present.", 3, nd.get().getOptions().length);
+ assertFalse("Second fixable skipped.", resolveCalled2.get());
+ assertTrue("Second question NOT displayed.",
+ displayer.notifyNow.stream().noneMatch(d -> d.getMessage().toString().contains("failed: NotResolved2")));
+ }
+
+ /**
+ * Let the user ignore the 1st reported problem. The next problem should be displayed after timeout and
+ * should proceed, of OKed.
+ */
+ public void testFirstProblemTimeoutNextOK() throws Exception {
+ ProjectAlertPresenter.QUESTION_WAKEUP_DELAY = 500;
+ setupTwoFixables();
+
+ Response r = new Response("Test Project", "Fixable1", USER_IGNORED);
+ r.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ r2.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r2);
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue(r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ TestedPresenter p = (TestedPresenter)tested.getPresenter(prj);
+ assertNotNull(p);
+
+ // wait for the timeout
+ assertTrue(p.timeoutSem.tryAcquire(10, TimeUnit.SECONDS));
+
+
+ // release the 2nd response
+ r2.responseLock.countDown();
+ assertTrue(r2.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled2.get());
+ }
+
+ /**
+ * Simulate ignoring the question, then a next alert() comes for the project, so the next
+ * question should be displayed.
+ * @throws Exception
+ */
+ public void testTimeoutNextDisplaysAfterAlert() throws Exception {
+ ProjectAlertPresenter.QUESTION_WAKEUP_DELAY = 100000;
+ setupTwoFixables();
+
+ Response r = new Response("Test Project", "Fixable1", USER_IGNORED);
+ r.responseSelected = new Semaphore(0);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ r2.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r2);
+
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue("First question ignored", r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ TestedPresenter p = (TestedPresenter)tested.getPresenter(prj);
+ assertNotNull(p);
+
+ // wait some considerable time
+ Thread.sleep(500);
+ assertEquals("Second response not picked yet", 0, r2.responseSelected.availablePermits());
+ assertEquals("Second dialog not displayed yet", 1, displayer.notifyNow.size());
+
+ tested.showAlert(prj);
+
+ assertTrue("Second question selected", r2.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ assertEquals("Second dialog displayer", 2, displayer.notifyNow.size());
+
+ r2.responseLock.countDown();
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled2.get());
+ assertFalse(resolveCalled.get());
+ }
+
+ /**
+ * If a next problem is discovered during a problem's fix, the additional problem will be displayed at the end,
+ * but will not obscur existing conversation.
+ */
+ public void testDisplayAdditionalProblem() throws Exception {
+ pdir = createSimpleProject();
+
+
+ AtomicBoolean resolveCalled3 = new AtomicBoolean();
+ AtomicReference<ProjectProblemsProvider.ProjectProblem> ref3 = new AtomicReference<>();
+ ProjectProblemsProvider.ProjectProblem pp3 = ProjectProblemsProvider.ProjectProblem.createError(
+ "TestFixable3",
+ "Fixable3",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.remove(ref3.get());
+ resolveCalled3.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref3.set(pp3);
+
+ ProjectProblemsProvider.ProjectProblem pp = ProjectProblemsProvider.ProjectProblem.createError(
+ "Test",
+ "Fixable1",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.remove(ref.get());
+ resolveCalled.set(true);
+ reporter.reportProblems.add(pp3);
+ // report an error
+ reporter.fireProblems();
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+
+ ProjectProblemsProvider.ProjectProblem pp2 = ProjectProblemsProvider.ProjectProblem.createError(
+ "TestFixable2",
+ "Fixable2",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.add(ref2.get());
+ resolveCalled2.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref.set(pp);
+ ref2.set(pp2);
+ reporter.reportProblems.add(pp);
+ reporter.reportProblems.add(pp2);
+
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ r2.responseSelected = new Semaphore(0);
+ r2.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r2);
+
+ Response r3 = new Response("Test Project", "Fixable3", NotifyDescriptor.OK_OPTION);
+ displayer.expectedResponses.add(r3);
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue("First question answered", r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ TestedPresenter p = (TestedPresenter)tested.getPresenter(prj);
+ assertNotNull(p);
+
+ r.responseLock.countDown();
+
+ assertTrue("2nd question answered", r2.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+ assertEquals("First 2 problems presented", 2, displayer.notifyNow.size());
+
+ r2.responseLock.countDown();
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled.get());
+ assertTrue(resolveCalled2.get());
+ assertTrue(resolveCalled3.get());
+
+ assertTrue("Late problem was presented", displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Fixable3")));
+
+ }
+
+ /**
+ * If an error is reported during problem fix, it is shown immediately
+ */
+ public void testDisplayAdditionalError() throws Exception {
+ pdir = createSimpleProject();
+ ProjectProblemsProvider.ProjectProblem pp = ProjectProblemsProvider.ProjectProblem.createError(
+ "Test",
+ "Fixable1",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.remove(ref.get());
+ resolveCalled.set(true);
+ reporter.reportProblems.add(ProjectProblemsProvider.ProjectProblem.createError("Test2", "Unfixable1", null));
+ // report an error
+ reporter.fireProblems();
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+
+ ProjectProblemsProvider.ProjectProblem pp2 = ProjectProblemsProvider.ProjectProblem.createError(
+ "TestFixable2",
+ "Fixable2",
+ new ProjectProblemResolver() {
+ @Override
+ public Future<ProjectProblemsProvider.Result> resolve() {
+ reporter.reportProblems.add(ref2.get());
+ resolveCalled2.set(true);
+ return CompletableFuture.completedFuture(ProjectProblemsProvider.Result.create(ProjectProblemsProvider.Status.RESOLVED));
+ }
+ }
+ );
+ ref.set(pp);
+ ref2.set(pp2);
+ reporter.reportProblems.add(pp);
+ reporter.reportProblems.add(pp2);
+
+ Response r = new Response("Test Project", "Fixable1", NotifyDescriptor.OK_OPTION);
+ r.responseSelected = new Semaphore(0);
+ r.responseLock = new CountDownLatch(1);
+ displayer.expectedResponses.add(r);
+
+ Response r2 = new Response("Test Project", "Fixable2", NotifyDescriptor.OK_OPTION);
+ displayer.expectedResponses.add(r2);
+
+ Response r3 = new Response("Test Project", "Unfixable1", NotifyDescriptor.OK_OPTION);
+ displayer.expectedResponses.add(r3);
+
+ Project prj = ProjectManager.getDefault().findProject(pdir);
+ OpenProjects.getDefault().open(new Project[] { prj } , true);
+ OpenProjects.getDefault().openProjects().get();
+
+ assertTrue("First question answered", r.responseSelected.tryAcquire(10, TimeUnit.SECONDS));
+
+ TestedPresenter p = (TestedPresenter)tested.getPresenter(prj);
+ assertNotNull(p);
+
+ r.responseLock.countDown();
+
+ assertTrue("The presenter finished", p.getCompletion().get());
+ assertTrue(resolveCalled.get());
+ assertTrue(resolveCalled2.get());
+
+ assertTrue("Late error was presented", displayer.notifyNow.stream().anyMatch(d -> d.getMessage().toString().contains("Unfixable1")));
+ }
+
+ static Map<Path, Collection> projectServices = Collections.synchronizedMap(new HashMap<>());
+
+ static class TestedPresenter extends ProjectAlertPresenter {
+ Semaphore timeoutSem = new Semaphore(0);
+
+ public TestedPresenter(Project project, BrokenReferencesModel model, Env master) {
+ super(project, model, master);
+ }
+
+ @Override
+ void processOneRound(Ctx ctx) {
+ super.processOneRound(ctx);
+ }
+
+ void resumeAfterTimeout(Ctx ctx) {
+ super.resumeAfterTimeout(ctx);
+ timeoutSem.release();
+ }
+ }
+
+ static class TestedImpl extends BrokenReferencesImpl {
+ Map<FileObject, Semaphore> presenterLock = Collections.synchronizedMap(new HashMap<>());
+ Map<FileObject, Semaphore> presenterNotify = Collections.synchronizedMap(new HashMap<>());
+
+ @Override
+ ProjectAlertPresenter createPresenter(Project project, BrokenReferencesModel model) {
+ return new TestedPresenter(project, model, this);
+ }
+
+ @Override
+ public CompletableFuture<Void> showAlert(@NonNull Project project) {
+ Semaphore s = presenterLock.get(project.getProjectDirectory());
+ if (s != null) {
+ try {
+ s.acquire();
+ } catch (InterruptedException ex) {
+ fail();
+ }
+ }
+ CompletableFuture<Void> r = super.showAlert(project);
+ s = presenterNotify.get(project.getProjectDirectory());
+ if (s != null) {
+ s.release();
+ }
+ return r;
+ }
+ }
+
+ static class Response {
+ String projectName;
+ String description;
+ String label;
+ Object response;
+ volatile Semaphore responseSelected;
+ volatile CountDownLatch responseLock;
+
+ public Response(String projectName, String label, Object response) {
+ this.projectName = projectName;
+ this.label = label;
+ this.response = response;
+ }
+
+ public Response(String projectName, String label, String description, Object response) {
+ this.projectName = projectName;
+ this.description = description;
+ this.label = label;
+ this.response = response;
+ }
+
+ public boolean matches(NotifyDescriptor d) {
+ return (d.getTitle().contains(projectName) && (label == null || d.getMessage().toString().contains(label)));
+ }
+
+ public Object respond(NotifyDescriptor d) {
+ if (responseSelected != null) {
+ responseSelected.release();
+ }
+ if (responseLock != null) {
+ try {
+ responseLock.await();
+ } catch (InterruptedException ex) {
+ fail();
+ }
+ }
+ return response;
+ }
+ }
+
+ /**
+ * Controllable problem reporter.
+ */
+ static class TestProblemReporter implements ProjectProblemsProvider {
+ List<ProjectProblem> reportProblems = Collections.synchronizedList(new ArrayList<>());
+ Semaphore response = new Semaphore(50);
+ Semaphore called = new Semaphore(0);
+ PropertyChangeSupport supp = new PropertyChangeSupport(this);
+
+ void clear() {
+ reportProblems.clear();
+ supp.firePropertyChange(PROP_PROBLEMS, null, null);
+ }
+
+ void fireProblems() {
+ supp.firePropertyChange(PROP_PROBLEMS, null, null);
+ }
+
+ @Override
+ public void addPropertyChangeListener(PropertyChangeListener listener) {
+ supp.addPropertyChangeListener(listener);
+ }
+
+ @Override
+ public void removePropertyChangeListener(PropertyChangeListener listener) {
+ supp.removePropertyChangeListener(listener);
+ }
+
+ @Override
+ public Collection<? extends ProjectProblem> getProblems() {
+ try {
+ called.release();
+ response.acquire();
+ return reportProblems;
+ } catch (InterruptedException ex) {
+ fail();
+ return null;
+ }
+ }
+ }
+
+ static final String USER_IGNORED = "user-ignore";
+
+ /**
+ * Mock displayer, so that user responses can be controlled.
+ */
+ static class DialogController extends DialogDisplayer {
+ final List<NotifyDescriptor> notifyLater = Collections.synchronizedList(new ArrayList<>());
+ final List<NotifyDescriptor> notifyNow = Collections.synchronizedList(new ArrayList<>());
+
+ final Semaphore responsePermits = new Semaphore(100);
+ final List<Response> expectedResponses = Collections.synchronizedList(new ArrayList<>());
+ final List<Response> answeredResponses = Collections.synchronizedList(new ArrayList<>());
+ volatile Response currentResponse = null;
+ final Semaphore responseAnswered = new Semaphore(0);
+
+ @Override
+ public Object notify(NotifyDescriptor descriptor) {
+ try {
+ notifyNow.add(descriptor);
+ responsePermits.acquire();
+ } catch (InterruptedException ex) {
+ fail("Interrupted");
+ }
+
+ List<Response> lst = new ArrayList<>(expectedResponses);
+ for (Response r : lst) {
+ if (r.matches(descriptor)) {
+ expectedResponses.remove(r);
+ currentResponse = r;
+ responseAnswered.release();
+ Object o = r.respond(descriptor);
+ answeredResponses.add(r);
+ if (o == USER_IGNORED) {
+ synchronized (this) {
+ try {
+ // block
+ wait();
+ } catch (InterruptedException ex) {
+ fail();
+ }
+ }
+ o = NotifyDescriptor.CLOSED_OPTION;
+ }
+ descriptor.setValue(o);
+ currentResponse = null;
+ return o;
+ }
+ }
+ fail("Unexpected NotifyDescriptor: " + descriptor);
+ return null;
+ }
+
+ @Override
+ public void notifyLater(NotifyDescriptor descriptor) {
+ notifyLater.add(descriptor);
+ }
+
+ @Override
+ public Dialog createDialog(DialogDescriptor descriptor) {
+ fail();
+ return null;
+ }
+
+ // ensure greater throughput for the otherwise simple default DialogDescriptor.notifyFuture
+ RequestProcessor futureProcessor = new RequestProcessor("requests", 20);
+
+ public <T extends NotifyDescriptor> CompletableFuture<T> notifyFuture(final T descriptor) {
+ CompletableFuture<T> r = new CompletableFuture<>();
+ // preserve potential context
+ Lookup def = Lookup.getDefault();
+ futureProcessor.post(new Runnable() {
+ public void run() {
+ Lookups.executeWith(def, () -> {
+ try {
+ DialogController.this.notify(descriptor);
+ r.complete(descriptor);
+ } catch (ThreadDeath td) {
+ throw td;
+ } catch (Throwable t) {
+ r.completeExceptionally(t);
+ }
+ });
+ }
+ });
+ return r;
+ }
+ }
+
+
+ public static LookupProvider forProjectServices() {
+ return new ProjectTestServices();
+ }
+
+ /**
+ * This class is registered in the (maven) project Lookup, so that
+ * the test can mock / inject problem reporter service.
+ */
+ private static class ProjectTestServices implements LookupProvider {
+ @Override
+ public Lookup createAdditionalLookup(Lookup baseContext) {
+ Project p = baseContext.lookup(Project.class);
+ Collection services = projectServices.get(FileUtil.toFile(p.getProjectDirectory()).toPath());
+ if (services == null) {
+ return Lookup.EMPTY;
+ } else {
+ return Lookups.fixed(services.toArray(new Object[services.size()]));
+ }
+ }
+ }
+
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@netbeans.apache.org
For additional commands, e-mail: commits-help@netbeans.apache.org
For further information about the NetBeans mailing lists, visit:
https://cwiki.apache.org/confluence/display/NETBEANS/Mailing+lists