GO-18846: Display suggestions when there are no services in the Services tool window

(cherry picked from commit 33f10e2419123d8e68c937323ef7ef147f3bc4f8)

IJ-CR-168429

GitOrigin-RevId: 4817144fc4c9e73e8a9efe6e44ba50fcdb751d38
This commit is contained in:
Artem Aleksyuk
2025-07-01 18:18:38 +02:00
committed by intellij-monorepo-bot
parent 55628f96a0
commit d9735a4a45
5 changed files with 127 additions and 42 deletions

View File

@@ -0,0 +1,59 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.execution.serviceView
import com.intellij.execution.ExecutionBundle
import com.intellij.execution.services.ServiceViewEmptyTreeSuggestion
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.actionSystem.impl.ActionButtonUtil
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.JBPopupListener
import com.intellij.openapi.ui.popup.LightweightWindowEvent
import java.awt.event.InputEvent
import javax.swing.Icon
class AddServiceEmptyTreeSuggestion : ServiceViewEmptyTreeSuggestion {
private val ADD_SERVICE_ACTION_ID: String = "ServiceView.AddService"
// Always the last
override val weight: Int = -10
override val icon: Icon? = AllIcons.General.Add
override val text: String = ExecutionBundle.message("service.view.empty.tree.suggestion.add.service")
override val shortcutText: String?
get() {
val addAction = ActionManager.getInstance().getAction(ADD_SERVICE_ACTION_ID)
val shortcutSet = addAction?.shortcutSet
val shortcut = shortcutSet?.getShortcuts()?.firstOrNull() ?: return null
return KeymapUtil.getShortcutText(shortcut)
}
override fun onActivate(dataContext: DataContext, inputEvent: InputEvent?) {
val selectedView = ServiceViewActionProvider.getSelectedView(dataContext) ?: return
val action = ActionManager.getInstance().getAction(ADD_SERVICE_ACTION_ID)
val actionGroup = action as? ActionGroup ?: return
val popup = JBPopupFactory.getInstance().createActionGroupPopup(
"",
actionGroup,
dataContext,
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
false,
ActionPlaces.getActionGroupPopupPlace(ADD_SERVICE_ACTION_ID))
val button = ActionButtonUtil.findActionButtonById(selectedView, ADD_SERVICE_ACTION_ID) ?: return
popup.addListener(object : JBPopupListener {
override fun beforeShown(event: LightweightWindowEvent) {
Toggleable.setSelected(button, true)
}
override fun onClosed(event: LightweightWindowEvent) {
Toggleable.setSelected(button, null)
}
})
popup.showUnderneathOf(button)
}
}

View File

@@ -2,19 +2,14 @@
package com.intellij.platform.execution.serviceView;
import com.intellij.execution.ExecutionBundle;
import com.intellij.execution.services.ServiceEventListener;
import com.intellij.execution.services.ServiceViewContributor;
import com.intellij.execution.services.ServiceViewDescriptor;
import com.intellij.execution.services.ServiceViewManager;
import com.intellij.execution.services.*;
import com.intellij.ide.DataManager;
import com.intellij.ide.dnd.DnDManager;
import com.intellij.ide.util.treeView.TreeState;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.actionSystem.*;
import com.intellij.openapi.actionSystem.DataContext;
import com.intellij.openapi.application.AppUIExecutor;
import com.intellij.openapi.keymap.KeymapUtil;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.popup.JBPopupFactory;
import com.intellij.openapi.util.Comparing;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.Pair;
@@ -24,7 +19,6 @@ import com.intellij.platform.execution.serviceView.ServiceViewNavBarService.Serv
import com.intellij.platform.navbar.frontend.vm.NavBarVm;
import com.intellij.ui.AutoScrollToSourceHandler;
import com.intellij.ui.SimpleTextAttributes;
import com.intellij.ui.awt.RelativePoint;
import com.intellij.ui.tree.AsyncTreeModel;
import com.intellij.ui.tree.RestoreSelectionListener;
import com.intellij.ui.tree.TreeVisitor;
@@ -47,8 +41,7 @@ import javax.swing.*;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.InputEvent;
import java.lang.ref.WeakReference;
import java.util.*;
import java.util.List;
@@ -58,8 +51,6 @@ import java.util.concurrent.CancellationException;
import static com.intellij.platform.execution.serviceView.ServiceViewDragHelper.getTheOnlyRootContributor;
final class ServiceTreeView extends ServiceView {
private static final String ADD_SERVICE_ACTION_ID = "ServiceView.AddService";
private final ServiceViewTree myTree;
private final ServiceViewTreeModel myTreeModel;
private final ServiceViewModel.ServiceViewModelListener myListener;
@@ -459,41 +450,37 @@ final class ServiceTreeView extends ServiceView {
}
private static void setEmptyText(JComponent component, StatusText emptyText) {
emptyText.withUnscaledGapAfter(5);
emptyText.setText(ExecutionBundle.message("service.view.empty.tree.text"));
emptyText.appendSecondaryText(ExecutionBundle.message("service.view.add.service.action.name"),
SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES,
new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
ActionGroup addActionGroup = ObjectUtils.tryCast(
ActionManager.getInstance().getAction(ADD_SERVICE_ACTION_ID), ActionGroup.class);
if (addActionGroup == null) return;
Point position = component.getMousePosition();
if (position == null) {
Rectangle componentBounds = component.getBounds();
Rectangle textBounds = emptyText.getComponent().getBounds();
position = new Point(componentBounds.width / 2,
componentBounds.height / (emptyText.isShowAboveCenter() ? 3 : 2) +
textBounds.height / 4);
}
DataContext dataContext = DataManager.getInstance().getDataContext(component);
JBPopupFactory.getInstance().createActionGroupPopup(
addActionGroup.getTemplatePresentation().getText(), addActionGroup, dataContext,
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
false, null, -1, null, ActionPlaces.getActionGroupPopupPlace(ADD_SERVICE_ACTION_ID))
.show(new RelativePoint(component, position));
}
});
AnAction addAction = ActionManager.getInstance().getAction(ADD_SERVICE_ACTION_ID);
ShortcutSet shortcutSet = addAction == null ? null : addAction.getShortcutSet();
Shortcut shortcut = shortcutSet == null ? null : ArrayUtil.getFirstElement(shortcutSet.getShortcuts());
if (shortcut != null) {
emptyText.appendSecondaryText(" (" + KeymapUtil.getShortcutText(shortcut) + ")", StatusText.DEFAULT_ATTRIBUTES, null);
var sortedSuggestions = getEmptyTreeSuggestions();
for (int i = 0; i < sortedSuggestions.size(); i++) {
ServiceViewEmptyTreeSuggestion suggestion = sortedSuggestions.get(i);
String suggestionText = suggestion.getText();
Icon icon = suggestion.getIcon();
emptyText.appendText(0, i + 1, icon, suggestionText, SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES, e -> {
InputEvent inputEvent = e.getSource() instanceof InputEvent ie ? ie : null;
DataContext dataContext = DataManager.getInstance().getDataContext(component);
suggestion.onActivate(dataContext, inputEvent);
});
String shortcutText = suggestion.getShortcutText();
if (shortcutText != null) {
String paddedText = " " + shortcutText;
emptyText.appendText(0, i + 1, icon, paddedText, SimpleTextAttributes.GRAYED_ATTRIBUTES, null);
}
}
}
@NotNull
private static List<ServiceViewEmptyTreeSuggestion> getEmptyTreeSuggestions() {
List<ServiceViewEmptyTreeSuggestion> externalSuggestions =
ContainerUtil.mapNotNull(ServiceViewContributor.CONTRIBUTOR_EP_NAME.getExtensionList(),
ServiceViewContributor::getEmptyTreeSuggestion);
var allSuggestions = ContainerUtil.append(externalSuggestions, new AddServiceEmptyTreeSuggestion());
var highWeightFirst = Comparator.comparingInt(ServiceViewEmptyTreeSuggestion::getWeight).reversed();
return ContainerUtil.sorted(allSuggestions, highWeightFirst);
}
private static List<TreePath> adjustPaths(List<? extends TreePath> paths, Collection<? extends ServiceViewItem> roots, Object treeRoot) {
List<TreePath> result = new SmartList<>();
for (TreePath path : paths) {

View File

@@ -370,6 +370,7 @@ run.dashboard.apply.to.all.types=<a>Apply to all types</a>
service.view.open.in.new.tab.ad.text=Drag node onto tool window header to open a new tab
service.view.empty.tree.text=No services configured.
service.view.empty.tree.suggestion.add.service=Add a service
service.view.empty.tab.text=No content available
service.view.empty.selection.text=Select service to view details
service.view.add.service.action.name=Add service

View File

@@ -3,6 +3,7 @@ package com.intellij.execution.services;
import com.intellij.openapi.extensions.ExtensionPointName;
import com.intellij.openapi.project.Project;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -37,4 +38,11 @@ public interface ServiceViewContributor<T> {
/// @return a [ServiceViewDescriptor] for the child node [T]
@NotNull
ServiceViewDescriptor getServiceDescriptor(@NotNull Project project, @NotNull T service);
/// @see ServiceViewEmptyTreeSuggestion
@ApiStatus.Internal
@Nullable
default ServiceViewEmptyTreeSuggestion getEmptyTreeSuggestion() {
return null;
}
}

View File

@@ -0,0 +1,30 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.execution.services
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.util.NlsSafe
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import java.awt.event.InputEvent
import javax.swing.Icon
/**
* Represents a suggestion displayed when there are no services in the Services tool window.
* Enhances the UX for users opening the Services tool window for the first time.
* @see ServiceViewContributor
*/
@ApiStatus.Internal
interface ServiceViewEmptyTreeSuggestion {
val weight: Int
val icon: Icon?
@get:Nls(capitalization = Nls.Capitalization.Sentence)
val text: String
@get:NlsSafe
val shortcutText: String?
get() = null
fun onActivate(dataContext: DataContext, inputEvent: InputEvent?)
}