This is an automated email from the ASF dual-hosted git repository. lkishalmi pushed a commit to branch delivery in repository https://gitbox.apache.org/repos/asf/netbeans.git
The following commit(s) were added to refs/heads/delivery by this push: new cee80c2 Infrastructure to display simple confirmations/questions in LSP client. (#2493) cee80c2 is described below commit cee80c2704dac3e35f71e5cefea67916a7ff7dc2 Author: Svatopluk Dedic <svatopluk.de...@oracle.com> AuthorDate: Mon Oct 26 23:45:51 2020 +0100 Infrastructure to display simple confirmations/questions in LSP client. (#2493) * Added A11Y properties to the panel. * Infrastructure to display simple confirmations/questions in LSP client. * Only translate html-starting content. * Leftover cleanup. --- .../modules/gradle/execute/TrustProjectPanel.java | 6 +- .../modules/project/ui/problems/Bundle.properties | 2 +- .../nbcode/integration/nbproject/project.xml | 8 + .../nbcode/integration/LspDialogDisplayer.java | 31 ++ java/java.lsp.server/nbproject/project.xml | 8 + .../modules/java/lsp/server/LspServerUtils.java | 91 ++++++ .../lsp/server/protocol/NbCodeLanguageClient.java | 4 + .../modules/java/lsp/server/protocol/Server.java | 229 +++++++++++---- .../lsp/server/protocol/WorkspaceUIContext.java | 9 +- .../lsp/server/ui/AbstractDialogDisplayer.java | 68 +++++ .../lsp/server/ui/NotifyDescriptorAdapter.java | 325 +++++++++++++++++++++ .../modules/java/lsp/server/ui/UIContext.java | 39 ++- .../lsp/server/ui/AbstractDialogDisplayerTest.java | 156 ++++++++++ 13 files changed, 910 insertions(+), 66 deletions(-) diff --git a/extide/gradle/src/org/netbeans/modules/gradle/execute/TrustProjectPanel.java b/extide/gradle/src/org/netbeans/modules/gradle/execute/TrustProjectPanel.java index e4bbfcf..910a70c 100644 --- a/extide/gradle/src/org/netbeans/modules/gradle/execute/TrustProjectPanel.java +++ b/extide/gradle/src/org/netbeans/modules/gradle/execute/TrustProjectPanel.java @@ -42,19 +42,23 @@ public class TrustProjectPanel extends javax.swing.JPanel { + " allows arbitrary code execution.</p>", "TrustProjectPanel.INFO_UNKNOWN=<html><p>NetBeans is about to invoke a Gradle build process.</p>" + " <p>Executing Gradle can be potentially un-safe as it" - + " allows arbitrary code execution.</p>" + + " allows arbitrary code execution.</p>", + "ProjectTrustDlg.TITLE=Not a Trusted Project" }) public TrustProjectPanel(Project project) { initComponents(); ProjectInformation info = project != null ? project.getLookup().lookup(ProjectInformation.class) : null; + getAccessibleContext().setAccessibleName(Bundle.ProjectTrustDlg_TITLE()); if (project == null) { cbTrustProject.setEnabled(false); cbTrustProject.setVisible(false); } if (info == null) { lbTrustMessage.setText(Bundle.TrustProjectPanel_INFO_UNKNOWN()); + getAccessibleContext().setAccessibleDescription(Bundle.TrustProjectPanel_INFO_UNKNOWN()); } else { lbTrustMessage.setText(Bundle.TrustProjectPanel_INFO(info.getDisplayName())); + getAccessibleContext().setAccessibleDescription(Bundle.TrustProjectPanel_INFO(info.getDisplayName())); } } diff --git a/ide/projectui/src/org/netbeans/modules/project/ui/problems/Bundle.properties b/ide/projectui/src/org/netbeans/modules/project/ui/problems/Bundle.properties index d022650..f9d5d34 100644 --- a/ide/projectui/src/org/netbeans/modules/project/ui/problems/Bundle.properties +++ b/ide/projectui/src/org/netbeans/modules/project/ui/problems/Bundle.properties @@ -20,7 +20,7 @@ ACSN_BrokenReferencesAlertPanel_notAgain=Do not show this message again ACSD_BrokenReferencesAlertPanel_notAgain=N/A MSG_Broken_References=<html>One or more project resources could not be found.<br>Right-click the project in the Projects window and choose<br><b>Resolve Project Problems</b> to find the missing resources.</html> ACSN_BrokenReferencesAlertPanel=Broken Project Panel -ACSD_BrokenReferencesAlertPanel=N/A +ACSD_BrokenReferencesAlertPanel=The project contains broken references or other problems. FMT_ProblemInProject={1} (in {0}) LBL_BrokenLinksCustomizer_List=Project &Problems: diff --git a/java/java.lsp.server/nbcode/integration/nbproject/project.xml b/java/java.lsp.server/nbcode/integration/nbproject/project.xml index ff60f27..3a36d95 100644 --- a/java/java.lsp.server/nbcode/integration/nbproject/project.xml +++ b/java/java.lsp.server/nbcode/integration/nbproject/project.xml @@ -51,6 +51,14 @@ </run-dependency> </dependency> <dependency> + <code-name-base>org.openide.dialogs</code-name-base> + <build-prerequisite/> + <compile-dependency/> + <run-dependency> + <specification-version>7.52</specification-version> + </run-dependency> + </dependency> + <dependency> <code-name-base>org.openide.util.lookup</code-name-base> <build-prerequisite/> <compile-dependency/> diff --git a/java/java.lsp.server/nbcode/integration/src/org/netbeans/modules/nbcode/integration/LspDialogDisplayer.java b/java/java.lsp.server/nbcode/integration/src/org/netbeans/modules/nbcode/integration/LspDialogDisplayer.java new file mode 100644 index 0000000..98e4416 --- /dev/null +++ b/java/java.lsp.server/nbcode/integration/src/org/netbeans/modules/nbcode/integration/LspDialogDisplayer.java @@ -0,0 +1,31 @@ +/* + * 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.AbstractDialogDisplayer; +import org.openide.DialogDisplayer; +import org.openide.util.lookup.ServiceProvider; + +/** + * + * @author sdedic + */ +//@ServiceProvider(service = DialogDisplayer.class, position = 1000) +public class LspDialogDisplayer extends AbstractDialogDisplayer { +} diff --git a/java/java.lsp.server/nbproject/project.xml b/java/java.lsp.server/nbproject/project.xml index 89d6327..7d0d84b 100644 --- a/java/java.lsp.server/nbproject/project.xml +++ b/java/java.lsp.server/nbproject/project.xml @@ -303,6 +303,14 @@ </run-dependency> </dependency> <dependency> + <code-name-base>org.openide.dialogs</code-name-base> + <build-prerequisite/> + <compile-dependency/> + <run-dependency> + <specification-version>7.52</specification-version> + </run-dependency> + </dependency> + <dependency> <code-name-base>org.openide.filesystems</code-name-base> <build-prerequisite/> <compile-dependency/> diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/LspServerUtils.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/LspServerUtils.java new file mode 100644 index 0000000..ab14aed --- /dev/null +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/LspServerUtils.java @@ -0,0 +1,91 @@ +/* + * 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; + +import org.netbeans.api.annotations.common.CheckForNull; +import org.netbeans.api.annotations.common.NonNull; +import org.netbeans.modules.java.lsp.server.protocol.NbCodeLanguageClient; +import org.netbeans.modules.java.lsp.server.protocol.Server; +import org.openide.util.Lookup; + +/** + * + * @author sdedic + */ +public class LspServerUtils { + + /** + * Locates the client associated with the current context. Use this method as a + * last resort, for testing & all other practical purposes it is always better to + * have own LSP client reference or a context Lookup instance. + * @param context the processing context. + * @return LanguageClient instance or {@code null}, if no client associated with the context or thread. + */ + @CheckForNull + public static final NbCodeLanguageClient findLspClient(Lookup context) { + NbCodeLanguageClient client = context != null ? context.lookup(NbCodeLanguageClient.class) : null; + if (client == null && context != Lookup.getDefault()) { + client = Lookup.getDefault().lookup(NbCodeLanguageClient.class); + } + return client; + } + + /** + * Locates the client associated with the current context. Use this method as a + * last resort, for testing & all other practical purposes it is always better to + * have own LSP client reference or a context Lookup instance. + * <p> + * This method always return a client, but if no real context is given, the client returns + * just stub values and logs all method calls as warnings. + * + * @param context the processing context. + * @return LanguageClient instance, never null. + */ + @NonNull + public static final NbCodeLanguageClient requireLspClient(Lookup context) { + NbCodeLanguageClient client = findLspClient(context); + return client != null ? client : Server.getStubClient(); + } + + /** + * Checks whether the calling thread is the one serving client's communication. + * Such thread cannot be blocked. If {@code null} is passed, returns {@code true} + * if the calling thread serves any client. + * + * @param client client instance or {@code null}. + * @return true, if communication with the client would be broken + */ + public static final boolean isClientResponseThread(NbCodeLanguageClient client) { + return Server.isClientResponseThread(client); + } + + /** + * Ensures that the caller does not serve the client associated with the context. If it does, + * throws IllegalStateException. Call to avoid blocking communication with the client before + * waiting on some remote response. + * + * @param context execution context + */ + public static final void avoidClientMessageThread(Lookup context) { + NbCodeLanguageClient client = LspServerUtils.findLspClient(context); + if (LspServerUtils.isClientResponseThread(client)) { + throw new IllegalStateException("Can not block LSP server message loop. Use RequestProcessor to run the calling code, or use notifyLater()"); + } + } +} diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java index 10ad5a6..06177ae 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/NbCodeLanguageClient.java @@ -46,4 +46,8 @@ public interface NbCodeLanguageClient extends LanguageClient { * @return code capabilities. */ public NbCodeClientCapabilities getNbCodeCapabilities(); + + public default boolean isRequestDispatcherThread() { + return Boolean.TRUE.equals(Server.DISPATCHERS.get()); + } } diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java index 91bf541..a67ad69 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/Server.java @@ -26,17 +26,24 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; import org.eclipse.lsp4j.CompletionOptions; import org.eclipse.lsp4j.ExecuteCommandOptions; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.ShowMessageRequestParams; import org.eclipse.lsp4j.TextDocumentSyncKind; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.jsonrpc.JsonRpcException; @@ -61,6 +68,7 @@ import org.netbeans.api.project.ui.OpenProjects; import org.openide.filesystems.FileObject; import org.openide.util.Exceptions; import org.openide.util.Lookup; +import org.openide.util.RequestProcessor; import org.openide.util.lookup.AbstractLookup; import org.openide.util.lookup.InstanceContent; import org.openide.util.lookup.Lookups; @@ -71,13 +79,28 @@ import org.openide.util.lookup.ProxyLookup; * @author lahvac */ public final class Server { + private static final Logger LOG = Logger.getLogger(Server.class.getName()); + private Server() { } + public static NbCodeLanguageClient getStubClient() { + return STUB_CLIENT; + } + + public static boolean isClientResponseThread(NbCodeLanguageClient client) { + return client != null ? + DISPATCHERS.get() == client : + DISPATCHERS.get() != null; + } + public static void launchServer(InputStream in, OutputStream out) { LanguageServerImpl server = new LanguageServerImpl(); - Launcher<NbCodeLanguageClient> serverLauncher = createLauncher(server, in, out); - ((LanguageClientAware) server).connect(serverLauncher.getRemoteProxy()); + ConsumeWithLookup msgProcessor = new ConsumeWithLookup(server.getSessionLookup()); + Launcher<NbCodeLanguageClient> serverLauncher = createLauncher(server, in, out, msgProcessor::attachLookup); + NbCodeLanguageClient remote = serverLauncher.getRemoteProxy(); + ((LanguageClientAware) server).connect(remote); + msgProcessor.attachClient(server.client); Future<Void> runningServer = serverLauncher.startListening(); try { runningServer.get(); @@ -86,39 +109,56 @@ public final class Server { } } - private static Launcher<NbCodeLanguageClient> createLauncher(LanguageServerImpl server, InputStream in, OutputStream out) { + private static Launcher<NbCodeLanguageClient> createLauncher(LanguageServerImpl server, InputStream in, OutputStream out, + Function<MessageConsumer, MessageConsumer> processor) { return new LSPLauncher.Builder<NbCodeLanguageClient>() .setLocalService(server) .setRemoteInterface(NbCodeLanguageClient.class) .setInput(in) .setOutput(out) - .wrapMessages(new ConsumeWithLookup(server.getSessionLookup())::attachLookup) + .wrapMessages(processor) .create(); } + static final ThreadLocal<NbCodeLanguageClient> DISPATCHERS = new ThreadLocal<>(); + /** * Processes message while the default Lookup is set to * {@link LanguageServerImpl#getSessionLookup()}. */ private static class ConsumeWithLookup { private final Lookup sessionLookup; - + private NbCodeLanguageClient client; + public ConsumeWithLookup(Lookup sessionLookup) { this.sessionLookup = sessionLookup; } + synchronized void attachClient(NbCodeLanguageClient client) { + this.client = client; + } + public MessageConsumer attachLookup(MessageConsumer delegate) { return new MessageConsumer() { @Override public void consume(Message msg) throws MessageIssueException, JsonRpcException { - Lookups.executeWith(sessionLookup, () -> { - delegate.consume(msg); - }); + try { + DISPATCHERS.set(client); + Lookups.executeWith(sessionLookup, () -> { + delegate.consume(msg); + }); + } finally { + DISPATCHERS.remove(); + } } }; } } + // change to a greater throughput if the initialization waits on more processes than just (serialized) project open. + private static final RequestProcessor SERVER_INIT_RP = new RequestProcessor(LanguageServerImpl.class.getName()); + + private static class LanguageServerImpl implements LanguageServer, LanguageClientAware { private static final Logger LOG = Logger.getLogger(LanguageServerImpl.class.getName()); @@ -135,62 +175,45 @@ public final class Server { return sessionLookup; } - @Override - public CompletableFuture<InitializeResult> initialize(InitializeParams init) { - NbCodeClientCapabilities capa = NbCodeClientCapabilities.get(init); - client.setClientCaps(capa); - List<FileObject> projectCandidates = new ArrayList<>(); - List<WorkspaceFolder> folders = init.getWorkspaceFolders(); - if (folders != null) { - for (WorkspaceFolder w : folders) { - try { - projectCandidates.add(TextDocumentServiceImpl.fromUri(w.getUri())); - } catch (MalformedURLException ex) { - LOG.log(Level.FINE, null, ex); + private void asyncOpenSelectedProjects(CompletableFuture f, List<FileObject> projectCandidates) { + List<Project> projects = new ArrayList<>(); + try { + for (FileObject candidate : projectCandidates) { + Project prj = FileOwnerQuery.getOwner(candidate); + if (prj != null) { + projects.add(prj); } } - } else { - String root = init.getRootUri(); - - if (root != null) { - try { - projectCandidates.add(TextDocumentServiceImpl.fromUri(root)); - } catch (MalformedURLException ex) { - LOG.log(Level.FINE, null, ex); + try { + Project[] previouslyOpened = OpenProjects.getDefault().openProjects().get(); + if (previouslyOpened.length > 0) { + Level level = Level.FINEST; + assert (level = Level.CONFIG) != null; + for (Project p : previouslyOpened) { + LOG.log(level, "Previously opened project at {0}", p.getProjectDirectory()); + } } - } else { - //TODO: use getRootPath()? + } catch (InterruptedException | ExecutionException ex) { + throw new IllegalStateException(ex); } - } - List<Project> projects = new ArrayList<>(); - for (FileObject candidate : projectCandidates) { - Project prj = FileOwnerQuery.getOwner(candidate); - if (prj != null) { - projects.add(prj); + OpenProjects.getDefault().open(projects.toArray(new Project[0]), false); + try { + OpenProjects.getDefault().openProjects().get(); + } catch (InterruptedException | ExecutionException ex) { + throw new IllegalStateException(ex); } - } - try { - Project[] previouslyOpened = OpenProjects.getDefault().openProjects().get(); - if (previouslyOpened.length > 0) { - Level level = Level.FINEST; - assert (level = Level.CONFIG) != null; - for (Project p : previouslyOpened) { - LOG.log(level, "Previously opened project at {0}", p.getProjectDirectory()); - } + for (Project prj : projects) { + //init source groups/FileOwnerQuery: + ProjectUtils.getSources(prj).getSourceGroups(Sources.TYPE_GENERIC); } - } catch (InterruptedException | ExecutionException ex) { - throw new IllegalStateException(ex); - } - OpenProjects.getDefault().open(projects.toArray(new Project[0]), false); - try { - OpenProjects.getDefault().openProjects().get(); - } catch (InterruptedException | ExecutionException ex) { - throw new IllegalStateException(ex); - } - for (Project prj : projects) { - //init source groups/FileOwnerQuery: - ProjectUtils.getSources(prj).getSourceGroups(Sources.TYPE_GENERIC); + Project[] prjs = projects.toArray(new Project[projects.size()]); + f.complete(prjs); + } catch (RuntimeException ex) { + f.completeExceptionally(ex); } + } + + private void showIndexingCompleted() { try { JavaSource.create(ClasspathInfo.create(ClassPath.EMPTY, ClassPath.EMPTY, ClassPath.EMPTY)) .runWhenScanFinished(cc -> { @@ -204,6 +227,9 @@ public final class Server { } catch (IOException ex) { throw new IllegalStateException(ex); } + } + + private InitializeResult constructInitResponse() { ServerCapabilities capabilities = new ServerCapabilities(); capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental); CompletionOptions completionOptions = new CompletionOptions(); @@ -216,7 +242,42 @@ public final class Server { capabilities.setDocumentHighlightProvider(true); capabilities.setReferencesProvider(true); capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(Arrays.asList(JAVA_BUILD_WORKSPACE, GRAALVM_PAUSE_SCRIPT))); - return CompletableFuture.completedFuture(new InitializeResult(capabilities)); + return new InitializeResult(capabilities); + } + + @Override + public CompletableFuture<InitializeResult> initialize(InitializeParams init) { + NbCodeClientCapabilities capa = NbCodeClientCapabilities.get(init); + client.setClientCaps(capa); + List<FileObject> projectCandidates = new ArrayList<>(); + List<WorkspaceFolder> folders = init.getWorkspaceFolders(); + if (folders != null) { + for (WorkspaceFolder w : folders) { + try { + projectCandidates.add(TextDocumentServiceImpl.fromUri(w.getUri())); + } catch (MalformedURLException ex) { + LOG.log(Level.FINE, null, ex); + } + } + } else { + String root = init.getRootUri(); + + if (root != null) { + try { + projectCandidates.add(TextDocumentServiceImpl.fromUri(root)); + } catch (MalformedURLException ex) { + LOG.log(Level.FINE, null, ex); + } + } else { + //TODO: use getRootPath()? + } + } + CompletableFuture<Project[]> fProjects = new CompletableFuture<>(); + SERVER_INIT_RP.post(() -> asyncOpenSelectedProjects(fProjects, projectCandidates)); + + return fProjects. + thenRun(this::showIndexingCompleted). + thenApply((v) -> constructInitResponse()); } @Override @@ -241,7 +302,7 @@ public final class Server { @Override public void connect(LanguageClient aClient) { this.client = new NbCodeClientWrapper((NbCodeLanguageClient)aClient); - + sessionServices.add(client); sessionServices.add(new WorkspaceIOContext() { @Override protected LanguageClient client() { @@ -254,8 +315,56 @@ public final class Server { ((LanguageClientAware) getWorkspaceService()).connect(aClient); } } - + public static final String JAVA_BUILD_WORKSPACE = "java.build.workspace"; public static final String GRAALVM_PAUSE_SCRIPT = "graalvm.pause.script"; static final String INDEXING_COMPLETED = "Indexing completed."; + + static final NbCodeLanguageClient STUB_CLIENT = new NbCodeLanguageClient() { + private final NbCodeClientCapabilities caps = new NbCodeClientCapabilities(); + + private void logWarning(Object... args) { + LOG.log(Level.WARNING, "LSP Client called without proper context with param(s): {0}", + Arrays.asList(args)); + } + + @Override + public void showStatusBarMessage(ShowStatusMessageParams params) { + logWarning(params); + } + + @Override + public NbCodeClientCapabilities getNbCodeCapabilities() { + logWarning(); + return caps; + } + + @Override + public void telemetryEvent(Object object) { + logWarning(object); + } + + @Override + public void publishDiagnostics(PublishDiagnosticsParams diagnostics) { + logWarning(diagnostics); + } + + @Override + public void showMessage(MessageParams messageParams) { + logWarning(messageParams); + } + + @Override + public CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams requestParams) { + logWarning(requestParams); + CompletableFuture<MessageActionItem> x = new CompletableFuture<>(); + x.complete(null); + return x; + } + + @Override + public void logMessage(MessageParams message) { + logWarning(message); + } + }; } diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceUIContext.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceUIContext.java index c20575c..eb66a47 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceUIContext.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/protocol/WorkspaceUIContext.java @@ -18,8 +18,10 @@ */ package org.netbeans.modules.java.lsp.server.protocol; +import java.util.concurrent.CompletableFuture; +import org.eclipse.lsp4j.MessageActionItem; import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.ShowMessageRequestParams; import org.netbeans.modules.java.lsp.server.ui.UIContext; import org.openide.awt.StatusDisplayer; @@ -40,6 +42,11 @@ class WorkspaceUIContext extends UIContext { } @Override + protected CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams msg) { + return client.showMessageRequest(msg); + } + + @Override protected void showMessage(MessageParams msg) { client.showMessage(msg); } diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/AbstractDialogDisplayer.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/AbstractDialogDisplayer.java new file mode 100644 index 0000000..35e05a0 --- /dev/null +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/AbstractDialogDisplayer.java @@ -0,0 +1,68 @@ +/* + * 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.awt.Dialog; +import java.awt.HeadlessException; +import org.netbeans.modules.java.lsp.server.LspServerUtils; +import org.openide.DialogDescriptor; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.util.Lookup; + +/** + * Remoting implementation of {@link DialogDisplayer}. The implementation will refuse to + * display dialogs that block the message processing thread ({@link IllegalStateException} will be thrown. + * + * @author sdedic + */ +public class AbstractDialogDisplayer extends DialogDisplayer { + private final Lookup context; + + public AbstractDialogDisplayer() { + this(Lookup.getDefault()); + } + + AbstractDialogDisplayer(Lookup context) { + this.context = context; + } + + @Override + public Object notify(NotifyDescriptor descriptor) { + LspServerUtils.avoidClientMessageThread(context); + UIContext ctx = UIContext.find(context); + NotifyDescriptorAdapter adapter = new NotifyDescriptorAdapter(descriptor, ctx); + return adapter.clientNotify(); + } + + @Override + public void notifyLater(final NotifyDescriptor descriptor) { + UIContext ctx = context.lookup(UIContext.class); + if (ctx == null) { + ctx = UIContext.find(); + } + NotifyDescriptorAdapter adapter = new NotifyDescriptorAdapter(descriptor, ctx); + adapter.clientNotifyLater(); + } + + @Override + public Dialog createDialog(DialogDescriptor descriptor) { + throw new HeadlessException(); + } +} diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/NotifyDescriptorAdapter.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/NotifyDescriptorAdapter.java new file mode 100644 index 0000000..f10318e --- /dev/null +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/NotifyDescriptorAdapter.java @@ -0,0 +1,325 @@ +/* + * 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.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import javax.accessibility.Accessible; +import javax.accessibility.AccessibleContext; +import javax.accessibility.AccessibleIcon; +import javax.accessibility.AccessibleText; +import javax.swing.Icon; +import javax.swing.JButton; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.openide.NotifyDescriptor; +import org.openide.util.NbBundle; + +/** + * Adapts a {@link NotifyDescriptor} to a {@link ShowMessageRequestParams} call. + * @author sdedic + */ +class NotifyDescriptorAdapter { + private static final Logger LOG = Logger.getLogger(NotifyDescriptorAdapter.class.getName()); + + private final UIContext client; + private final NotifyDescriptor descriptor; + private final Map<MessageActionItem, Object> item2Option = new LinkedHashMap<>(); + private final Map<String, Object> text2Option = new HashMap<>(); + private final Map<Object, List<ActionListener>> optionListeners = new HashMap<>(); + private final Map<Object, JButton> option2Button = new HashMap<>(); + + private static final Set<String> warnedClasses = new HashSet<>(); + + private ShowMessageRequestParams request; + + private static final Object[] YES_NO_CANCEL = new Object[] { + NotifyDescriptor.YES_OPTION, + NotifyDescriptor.NO_OPTION, + NotifyDescriptor.CANCEL_OPTION + }; + + private static final Object[] YES_NO = new Object[] { + NotifyDescriptor.YES_OPTION, + NotifyDescriptor.NO_OPTION, + }; + + private static final Object[] OK_CANCEL = new Object[] { + NotifyDescriptor.OK_OPTION, + NotifyDescriptor.CANCEL_OPTION + }; + + private static final Object[] JUST_OK = new Object[] { + NotifyDescriptor.OK_OPTION + }; + + public NotifyDescriptorAdapter(NotifyDescriptor descriptor, UIContext client) { + this.descriptor = descriptor; + this.client = client; + } + + private MessageType translateMessageType() { + switch(descriptor.getMessageType()) { + case NotifyDescriptor.ERROR_MESSAGE: + return MessageType.Error; + case NotifyDescriptor.WARNING_MESSAGE: + return MessageType.Warning; + + case NotifyDescriptor.QUESTION_MESSAGE: + case NotifyDescriptor.PLAIN_MESSAGE: + case NotifyDescriptor.INFORMATION_MESSAGE: + return MessageType.Info; + default: + return MessageType.Log; + } + } + + /** + * Strip HTML from the message; VSCode standard showMessage does not support HTML. + * @param original + * @return + */ + private String translateText(String original) { + if (!original.startsWith("<html>")) { // NOI18N + return original; + } + String res = + original.replaceAll("<p/>|</p>|<br>", "\n"). // NOI18N + replaceAll( "<[^>]*>", "" ). // NOI18N + replaceAll( " ", " " ); // NOI18N + res = res.trim(); + return res; + } + + public String getAccessibleDescription(Object o) { + if (!(o instanceof Accessible)) { + return null; + } + AccessibleContext ac = ((Accessible)o).getAccessibleContext(); + String s = ac.getAccessibleDescription(); + if (s != null && !"N/A".equals(s)) { + return s; + } + return ac.getAccessibleName(); + } + + public ShowMessageRequestParams createShowMessageRequest() { + if (this.request != null) { + return request; + } + Object msg = descriptor.getMessage(); + String displayText = null; + + if (msg instanceof String) { + displayText = msg.toString(); + } else { + displayText = getAccessibleDescription(msg); + } + if (displayText == null) { + return null; + } + mapDescriptorOptions(); + ShowMessageRequestParams request = new ShowMessageRequestParams(); + request.setMessage(translateText(displayText)); + request.setActions(getActionItems()); + request.setType(translateMessageType()); + return this.request = request; + } + + public Object actionToOption(MessageActionItem item) { + return item2Option.get(item); + } + + public List<MessageActionItem> getActionItems() { + return new ArrayList<>(item2Option.keySet()); + } + + private void addMessageItem(MessageActionItem item, Object option) { + item2Option.put(item, option); + text2Option.put(item.getTitle(), option); + } + + @NbBundle.Messages({ + "OPTION_Yes=Yes", + "OPTION_No=No", + "OPTION_OK=OK", + "OPTION_Cancel=Cancel", + }) + private String mapDescriptorOption(Object option) { + if (option == NotifyDescriptor.CANCEL_OPTION) { + return Bundle.OPTION_Cancel(); + } else if (option == NotifyDescriptor.NO_OPTION) { + return Bundle.OPTION_No(); + } else if (option == NotifyDescriptor.YES_OPTION) { + return Bundle.OPTION_Yes(); + } else if (option == NotifyDescriptor.OK_OPTION) { + return Bundle.OPTION_OK(); + } + if (option instanceof Component) { + return null; + } + if (option != null) { + return option.toString(); + } + return null; + } + + private void mapDescriptorOptions() { + Object[] options = descriptor.getOptions(); + if (options == null) { + switch (descriptor.getOptionType()) { + case NotifyDescriptor.DEFAULT_OPTION: + case NotifyDescriptor.OK_CANCEL_OPTION: + options = OK_CANCEL; break; + case NotifyDescriptor.YES_NO_CANCEL_OPTION: + options = YES_NO_CANCEL; break; + case NotifyDescriptor.YES_NO_OPTION: + options = YES_NO; break; + default: + options = JUST_OK; + break; + } + } + for (Object o : options) { + String text; + + if (o instanceof JButton) { + text = addButtonItem((JButton)o); + } else if (o instanceof Icon) { + text = addIconItem((Icon)o); + } else { + text = mapDescriptorOption(o); + } + if (text != null) { + addMessageItem(new MessageActionItem(text), o); + } else { + reportUnknownOption(o); + } + } + } + + private void reportUnknownOption(Object o) { + Throwable t = new Throwable(); + StackTraceElement[] stack = t.getStackTrace(); + if (stack.length >= 7) { + String callerClass = stack[6].getClassName(); + synchronized (warnedClasses) { + if (!warnedClasses.add(callerClass)) { + return; + } + } + } + LOG.log(Level.WARNING, new Throwable(), + () -> "Unhandled option " + o + " for descriptor: " + descriptor); + } + + private String addButtonItem(JButton button) { + if (!button.isVisible()) { + return null; + } + String t = button.getText(); + List<ActionListener> ll = Arrays.asList(button.getActionListeners()); + if (!ll.isEmpty()) { + optionListeners.put(button, ll); + } + return t; + } + + private String addIconItem(Icon icon) { + if (icon instanceof AccessibleIcon) { + return ((AccessibleIcon)icon).getAccessibleIconDescription(); + } else { + return null; + } + } + + public Object clientNotify() { + try { + return clientNotifyLater().get(); + } catch (InterruptedException | ExecutionException ex) { + throw new IllegalStateException(ex); + } + } + + public CompletableFuture<Object> clientNotifyLater() { + ShowMessageRequestParams params = createShowMessageRequest(); + if (params == null) { + CompletableFuture<Object> x = new CompletableFuture<>(); + x.complete(NotifyDescriptor.CLOSED_OPTION); + return x; + } + CompletableFuture<MessageActionItem> resultItem = client.showMessageRequest(request); + return resultItem /*.exceptionally(this::handleClientException) */.thenApply(this::processActivatedOption); + } + + MessageActionItem handleClientException(Throwable t) { + // TBD + return null; + } + + Object processActivatedOption(MessageActionItem item) { + Object option = selectActivatedOption(item); + List<ActionListener> ll = optionListeners.get(option); + if (ll != null) { + ActionEvent e = new ActionEvent(option, ActionEvent.ACTION_PERFORMED, item.getTitle()); + for (ActionListener l : ll) { + try { + l.actionPerformed(e); + } catch (RuntimeException ex) { + LOG.log(Level.SEVERE, "Error occurred during actionListener dispatch", ex); + } + } + } + return option; + } + + Object selectActivatedOption(MessageActionItem item) { + if (item == null) { + return NotifyDescriptor.CLOSED_OPTION; + } + Object option = item2Option.get(item); + if (option == null) { + option = text2Option.get(item.getTitle()); + } + if (option == null) { + LOG.log(Level.WARNING, "Unknown client response received: {0}, the valid options were: {1}", new Object[] { + item.getTitle(), + item2Option.keySet().stream().map(MessageActionItem::getTitle).collect(Collectors.toList()) + }); + return NotifyDescriptor.CLOSED_OPTION; + } + + return option; + } +} diff --git a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/UIContext.java b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/UIContext.java index 781d04e..d820f4e 100644 --- a/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/UIContext.java +++ b/java/java.lsp.server/src/org/netbeans/modules/java/lsp/server/ui/UIContext.java @@ -20,17 +20,37 @@ package org.netbeans.modules.java.lsp.server.ui; import java.lang.ref.Reference; import java.lang.ref.WeakReference; +import java.util.concurrent.CompletableFuture; +import org.eclipse.lsp4j.MessageActionItem; import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.netbeans.api.annotations.common.NonNull; import org.netbeans.modules.java.lsp.server.protocol.ShowStatusMessageParams; import org.openide.awt.StatusDisplayer.Message; import org.openide.util.Lookup; public abstract class UIContext { private static Reference<UIContext> lastCtx = new WeakReference<>(null); - - public static synchronized UIContext find() { - UIContext ctx = Lookup.getDefault().lookup(UIContext.class); + + /** + * Allows to pass Lookup as a context to locate UIContext implementation; can be useful for tests. If not found + * in the context `lkp', will be searched in the default Lookup (if lkp is not the default one). + * @param lkp context lookup + * @return UIContext. + */ + @NonNull + public static synchronized UIContext find(Lookup lkp) { + UIContext ctx = lkp.lookup(UIContext.class); + if (ctx != null) { + return ctx; + } + Lookup def = Lookup.getDefault(); + if (lkp != def) { + ctx = def.lookup(UIContext.class); + } if (ctx == null) { + // PENDING: better context transfer between threads is needed; this way the UIContext can remote to a bad + // LSP client window ctx = lastCtx.get(); if (ctx != null && !ctx.isValid()) { lastCtx.clear(); @@ -49,8 +69,14 @@ public abstract class UIContext { return ctx; } + @NonNull + public static synchronized UIContext find() { + return find(Lookup.getDefault()); + } + protected abstract boolean isValid(); protected abstract void showMessage(MessageParams msg); + protected abstract CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams msg); protected abstract void logMessage(MessageParams msg); protected abstract Message showStatusMessage(ShowStatusMessageParams msg); @@ -62,6 +88,13 @@ public abstract class UIContext { } @Override + protected CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams msg) { + System.err.println(msg.getType() + ": " + msg.getMessage()); + CompletableFuture<MessageActionItem> ai = CompletableFuture.completedFuture(null); + return ai; + } + + @Override protected void showMessage(MessageParams msg) { System.err.println(msg.getType() + ": " + msg.getMessage()); } diff --git a/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/ui/AbstractDialogDisplayerTest.java b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/ui/AbstractDialogDisplayerTest.java new file mode 100644 index 0000000..9d9ec81 --- /dev/null +++ b/java/java.lsp.server/test/unit/src/org/netbeans/modules/java/lsp/server/ui/AbstractDialogDisplayerTest.java @@ -0,0 +1,156 @@ +/* + * 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.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import javax.swing.JPanel; +import static junit.framework.TestCase.fail; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.netbeans.junit.NbTestCase; +import org.netbeans.modules.java.lsp.server.protocol.ShowStatusMessageParams; +import org.openide.NotifyDescriptor; +import org.openide.awt.StatusDisplayer; + +/** + * + * @author sdedic + */ +public class AbstractDialogDisplayerTest extends NbTestCase { + + public AbstractDialogDisplayerTest(String name) { + super(name); + } + + private static class MockUIContext extends UIContext { + @Override + protected boolean isValid() { + return true; + } + + @Override + protected void showMessage(MessageParams msg) { + } + + @Override + protected CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams msg) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected void logMessage(MessageParams msg) { + } + + @Override + protected StatusDisplayer.Message showStatusMessage(ShowStatusMessageParams msg) { + return null; + } + } + + /** + * Checks that component-based dialogs will just return CLOSED. + * @throws Exception + */ + public void testUnsupportedDialogWithPanel() throws Exception { + MockUIContext client = new MockUIContext() { + @Override + public CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams requestParams) { + fail(); + return null; + } + + @Override + public void showMessage(MessageParams messageParams) { + fail(); + } + }; + + NotifyDescriptor nd = new NotifyDescriptor(new JPanel(), "Unused", + NotifyDescriptor.OK_CANCEL_OPTION, + NotifyDescriptor.WARNING_MESSAGE, + null, null); + + NotifyDescriptorAdapter adapter = new NotifyDescriptorAdapter(nd, client); + assertSame(NotifyDescriptor.CLOSED_OPTION, adapter.clientNotify()); + } + + private static final Map<Integer, MessageType> MESSAGE_TYPES = new HashMap<>(); + + { + MESSAGE_TYPES.put(NotifyDescriptor.PLAIN_MESSAGE, MessageType.Info); + MESSAGE_TYPES.put(NotifyDescriptor.QUESTION_MESSAGE, MessageType.Info); + MESSAGE_TYPES.put(NotifyDescriptor.INFORMATION_MESSAGE, MessageType.Info); + MESSAGE_TYPES.put(NotifyDescriptor.WARNING_MESSAGE, MessageType.Warning); + MESSAGE_TYPES.put(NotifyDescriptor.ERROR_MESSAGE, MessageType.Error); + } + + /** + * Checks that showMessage receives an appropriate message type, for different + * ND's {@code messageTy[e} value/ + * @throws Exception + */ + public void testCheckMessageTypes() throws Exception { + for (int i : MESSAGE_TYPES.keySet()) { + MockUIContext cl = new MockUIContext() { + MessageType check = MESSAGE_TYPES.get(i); + + public CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams requestParams) { + assertEquals(check, requestParams.getType()); + CompletableFuture<MessageActionItem> x = new CompletableFuture<>(); + x.complete(null); + return x; + } + }; + NotifyDescriptor nd = new NotifyDescriptor("Hello, LSP client", "Unused", + NotifyDescriptor.OK_CANCEL_OPTION, + i, + null, null); + NotifyDescriptorAdapter adapter = new NotifyDescriptorAdapter(nd, cl); + adapter.clientNotify(); + } + } + + /** + * Checks that yes-no-cancel items will be presented at the client + */ + public void testYesNoCancelItems() { + MockUIContext cl = new MockUIContext() { + public CompletableFuture<MessageActionItem> showMessageRequest(ShowMessageRequestParams requestParams) { + assertEquals(3, requestParams.getActions().size()); + assertEquals("Yes", requestParams.getActions().get(0).getTitle()); + assertEquals("No", requestParams.getActions().get(1).getTitle()); + assertEquals("Cancel", requestParams.getActions().get(2).getTitle()); + + CompletableFuture<MessageActionItem> x = new CompletableFuture<>(); + x.complete(requestParams.getActions().get(0)); + return x; + } + }; + NotifyDescriptor nd = new NotifyDescriptor("Hello, LSP client", "Unused", + NotifyDescriptor.YES_NO_CANCEL_OPTION, + NotifyDescriptor.QUESTION_MESSAGE, + null, null); + NotifyDescriptorAdapter adapter = new NotifyDescriptorAdapter(nd, cl); + assertEquals(NotifyDescriptor.YES_OPTION, adapter.clientNotify()); + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@netbeans.apache.org For additional commands, e-mail: commits-h...@netbeans.apache.org For further information about the NetBeans mailing lists, visit: https://cwiki.apache.org/confluence/display/NETBEANS/Mailing+lists