[debugger] API to visualize text values, IDEA-135082

GitOrigin-RevId: 1da6fc297d89596852cafd9112c48d26e9f55fec
This commit is contained in:
Vladimir Parfinenko
2024-06-19 10:29:53 +02:00
committed by intellij-monorepo-bot
parent 776be1535f
commit 8d6375c6c2
11 changed files with 373 additions and 101 deletions

View File

@@ -41,6 +41,7 @@ import com.intellij.xdebugger.impl.pinned.items.PinToTopMemberValue;
import com.intellij.xdebugger.impl.pinned.items.PinToTopParentValue;
import com.intellij.xdebugger.impl.ui.XValueTextProvider;
import com.intellij.xdebugger.impl.ui.tree.nodes.XValueNodeImpl;
import com.intellij.xdebugger.impl.ui.visualizedtext.VisualizedTextPopup;
import com.sun.jdi.*;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
@@ -204,7 +205,7 @@ public class JavaValue extends XNamedValue implements NodeDescriptorProvider, XV
}
});
}
else if (StringUtil.containsLineBreak(text)) {
else if (VisualizedTextPopup.INSTANCE.isVisualizable(text)) {
node.setFullValueEvaluator(new XFullValueEvaluator() {
@Override
public void startEvaluation(@NotNull XFullValueEvaluationCallback callback) {

View File

@@ -749,6 +749,17 @@ com.intellij.xdebugger.ui.DebuggerColors
- sf:NOT_TOP_FRAME_ATTRIBUTES:com.intellij.openapi.editor.colors.TextAttributesKey
- sf:SMART_STEP_INTO_SELECTION:com.intellij.openapi.editor.colors.TextAttributesKey
- sf:SMART_STEP_INTO_TARGET:com.intellij.openapi.editor.colors.TextAttributesKey
*:com.intellij.xdebugger.ui.TextValueVisualizer
- *sf:Companion:com.intellij.xdebugger.ui.TextValueVisualizer$Companion
- canVisualize(java.lang.String):Z
- a:visualize(java.lang.String):java.util.List
*f:com.intellij.xdebugger.ui.TextValueVisualizer$Companion
- f:getEP():com.intellij.openapi.extensions.ExtensionPointName
*:com.intellij.xdebugger.ui.VisualizedContentTab
- a:createComponent(com.intellij.openapi.project.Project):javax.swing.JComponent
- a:getId():java.lang.String
- a:getName():java.lang.String
- onShown(com.intellij.openapi.project.Project,Z):V
c:com.intellij.xdebugger.ui.XDebugTabLayouter
- <init>():V
- registerAdditionalContent(com.intellij.execution.ui.RunnerLayoutUi):V

View File

@@ -324,3 +324,5 @@ debugger.frame.list.help.description=Go back to the previous frame. This will re
action.TurnOffDfaAssist.text=Turn Off Data Flow Assist for This Session
action.TurnOffDfaAssist.description=Switch off data flow\u2013aided debugging for this session
xdebugger.visualized.text.name.raw=Raw

View File

@@ -0,0 +1,52 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.xdebugger.ui
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NlsSafe
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import javax.swing.JComponent
/**
* Extension point for visualization or formatting of plain text values obtained from debuggee's string-like entities.
* E.g., JSON might be pretty-printed with syntax highlighting, HTML might be rendered in a browser and can be pretty-printed as XML.
*/
@ApiStatus.Experimental // until we consider collection visualizers
interface TextValueVisualizer {
companion object {
val EP: ExtensionPointName<TextValueVisualizer> = ExtensionPointName.create("com.intellij.xdebugger.textValueVisualizer")
}
/**
* Returns whether this extension can visualize the given value.
* If `true`, then [visualize] returns a non-empty list.
*/
fun canVisualize(value: String): Boolean {
// Default implementation, feel free to override it if there is a more efficient way to check this.
return visualize(value).isNotEmpty()
}
/** Visualizes the given value, possibly in several ways. */
fun visualize(value: @NlsSafe String): List<VisualizedContentTab>
}
@ApiStatus.Experimental // until we consider collection visualizers
interface VisualizedContentTab {
/** Title of the tab with the content. */
val name: @Nls String
/** Internal ID of the tab used to remember the last used visualizer. */
val id: String
/** Create the visualized content component. */
fun createComponent(project: Project): JComponent
/**
* This callback is called when the tab is shown.
* If user switches between tabs, `firstTime` is `true` only when the tab is shown for the first time.
*/
fun onShown(project: Project, firstTime: Boolean) {
}
}

View File

@@ -3631,6 +3631,12 @@ c:com.intellij.xdebugger.impl.ui.tree.nodes.XValueTextRendererImpl
- renderSpecialSymbol(java.lang.String):V
- renderStringValue(java.lang.String,java.lang.String,I):V
- renderValue(java.lang.String):V
a:com.intellij.xdebugger.impl.ui.visualizedtext.TextBasedContentTab
- com.intellij.xdebugger.ui.VisualizedContentTab
- <init>():V
- createComponent(com.intellij.openapi.project.Project):javax.swing.JComponent
- pa:formatText():java.lang.String
- pa:getFileType():com.intellij.openapi.fileTypes.FileType
c:com.intellij.xdebugger.memory.action.ShowClassesWithDiffAction
- com.intellij.openapi.actionSystem.ToggleAction
- <init>():V

View File

@@ -22,6 +22,8 @@
<extensionPoint name="xdebugger.dialog.process.view.provider" interface="com.intellij.xdebugger.impl.ui.attach.dialog.extensions.XAttachToProcessViewProvider" dynamic="true"/>
<extensionPoint name="xdebugger.inlineBreakpointsDisabler" interface="com.intellij.xdebugger.breakpoints.InlineBreakpointsDisabler" dynamic="true"/>
<extensionPoint name="xdebugger.textValueVisualizer" interface="com.intellij.xdebugger.ui.TextValueVisualizer" dynamic="true"/>
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">

View File

@@ -6,6 +6,7 @@ import com.intellij.openapi.project.Project
import com.intellij.ui.AppUIUtil.invokeOnEdt
import com.intellij.ui.EditorTextField
import com.intellij.xdebugger.frame.XFullValueEvaluator
import com.intellij.xdebugger.impl.ui.visualizedtext.VisualizedTextPopup
import org.jetbrains.annotations.ApiStatus
import java.awt.CardLayout
import java.awt.Font
@@ -31,7 +32,7 @@ abstract class CustomComponentEvaluator(name: String) : XFullValueEvaluator() {
editor: Editor?,
component: JComponent,
cancelCallback: Runnable?) {
DebuggerUIUtil.showValuePopup(event, project, editor, component, cancelCallback)
VisualizedTextPopup.showValuePopup(event, project, editor, component, cancelCallback)
}
protected class EvaluationCallback(private val myPanel: JComponent,

View File

@@ -22,17 +22,14 @@ import com.intellij.openapi.fileTypes.FileTypes;
import com.intellij.openapi.keymap.KeymapUtil;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.ui.popup.*;
import com.intellij.openapi.util.DimensionService;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.NlsContexts;
import com.intellij.openapi.util.Ref;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.wm.IdeFocusManager;
import com.intellij.openapi.wm.WindowManager;
import com.intellij.ui.AppUIUtil;
import com.intellij.ui.ComponentUtil;
import com.intellij.ui.EditorTextField;
import com.intellij.ui.ScreenUtil;
import com.intellij.ui.awt.RelativePoint;
import com.intellij.ui.popup.list.ListPopupImpl;
@@ -53,6 +50,7 @@ import com.intellij.xdebugger.impl.frame.XWatchesView;
import com.intellij.xdebugger.impl.ui.tree.XDebuggerTree;
import com.intellij.xdebugger.impl.ui.tree.XDebuggerTreeState;
import com.intellij.xdebugger.impl.ui.tree.nodes.XValueNodeImpl;
import com.intellij.xdebugger.impl.ui.visualizedtext.VisualizedTextPopup;
import one.util.streamex.StreamEx;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NonNls;
@@ -62,7 +60,6 @@ import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import static com.intellij.openapi.wm.IdeFocusManager.getGlobalInstance;
@@ -138,44 +135,7 @@ public final class DebuggerUIUtil {
@NotNull MouseEvent event,
@NotNull Project project,
@Nullable Editor editor) {
if (evaluator instanceof CustomComponentEvaluator customComponentEvaluator) {
customComponentEvaluator.show(event, project, editor);
}
else {
EditorTextField textArea = createTextViewer(XDebuggerUIConstants.getEvaluatingExpressionMessage(), project);
final FullValueEvaluationCallbackImpl callback = new FullValueEvaluationCallbackImpl(textArea);
showValuePopup(event, project, editor, textArea, callback::setObsolete);
evaluator.startEvaluation(callback); /*to make it really cancellable*/
}
}
static void showValuePopup(@NotNull MouseEvent event,
@NotNull Project project,
@Nullable Editor editor,
JComponent component,
@Nullable Runnable cancelCallback) {
Dimension size = DimensionService.getInstance().getSize(FULL_VALUE_POPUP_DIMENSION_KEY, project);
if (size == null) {
Dimension frameSize = Objects.requireNonNull(WindowManager.getInstance().getFrame(project)).getSize();
size = new Dimension(frameSize.width / 2, frameSize.height / 2);
}
component.setPreferredSize(size);
JBPopup popup = createValuePopup(project, component, cancelCallback);
if (editor == null) {
Rectangle bounds = new Rectangle(event.getLocationOnScreen(), size);
ScreenUtil.fitToScreenVertical(bounds, 5, 5, true);
if (size.width != bounds.width || size.height != bounds.height) {
size = bounds.getSize();
component.setPreferredSize(size);
}
popup.showInScreenCoordinates(event.getComponent(), bounds.getLocation());
}
else {
popup.showInBestPositionFor(editor);
}
VisualizedTextPopup.INSTANCE.evaluateAndShowValuePopup(evaluator, event, project, editor);
}
@ApiStatus.Experimental
@@ -196,25 +156,6 @@ public final class DebuggerUIUtil {
return textArea;
}
@NotNull
private static FullValueEvaluationCallbackImpl startEvaluation(@NotNull TextViewer textViewer,
@NotNull XFullValueEvaluator evaluator,
@Nullable Runnable afterFullValueEvaluation) {
FullValueEvaluationCallbackImpl callback = new FullValueEvaluationCallbackImpl(textViewer) {
@Override
public void evaluated(@NotNull String fullValue, @Nullable Font font) {
super.evaluated(fullValue, font);
AppUIUtil.invokeOnEdt(() -> {
if (afterFullValueEvaluation != null) {
afterFullValueEvaluation.run();
}
});
}
};
evaluator.startEvaluation(callback);
return callback;
}
@ApiStatus.Experimental
public static ComponentPopupBuilder createTextViewerPopupBuilder(@NotNull JComponent popupContent,
@NotNull TextViewer textViewer,
@@ -222,15 +163,44 @@ public final class DebuggerUIUtil {
@NotNull Project project,
@Nullable Runnable afterFullValueEvaluation,
@Nullable Runnable hideRunnable) {
final @NotNull FullValueEvaluationCallbackImpl callback = startEvaluation(textViewer, evaluator, afterFullValueEvaluation);
AtomicBoolean evaluationObsolete = new AtomicBoolean(false);
var callback = new XFullValueEvaluator.XFullValueEvaluationCallback() {
@Override
public void evaluated(@NotNull final String fullValue, @Nullable final Font font) {
AppUIUtil.invokeOnEdt(() -> {
textViewer.setText(fullValue);
if (font != null) {
textViewer.setFont(font);
}
if (afterFullValueEvaluation != null) {
afterFullValueEvaluation.run();
}
});
}
@Override
public void errorOccurred(@NotNull final String errorMessage) {
AppUIUtil.invokeOnEdt(() -> {
textViewer.setForeground(XDebuggerUIConstants.ERROR_MESSAGE_ATTRIBUTES.getFgColor());
textViewer.setText(errorMessage);
});
}
@Override
public boolean isObsolete() {
return evaluationObsolete.get();
}
};
Runnable cancelCallback = () -> {
callback.setObsolete();
evaluationObsolete.set(true);
if (hideRunnable != null) {
hideRunnable.run();
}
};
evaluator.startEvaluation(callback);
return createCancelablePopupBuilder(project, popupContent, textViewer, cancelCallback, null);
}
@@ -414,42 +384,6 @@ public final class DebuggerUIUtil {
return EditorColorsUtil.getColorSchemeForComponent(component);
}
private static class FullValueEvaluationCallbackImpl implements XFullValueEvaluator.XFullValueEvaluationCallback {
private final AtomicBoolean myObsolete = new AtomicBoolean(false);
private final EditorTextField myTextArea;
FullValueEvaluationCallbackImpl(final EditorTextField textArea) {
myTextArea = textArea;
}
@Override
public void evaluated(@NotNull final String fullValue, @Nullable final Font font) {
AppUIUtil.invokeOnEdt(() -> {
myTextArea.setText(fullValue);
if (font != null) {
myTextArea.setFont(font);
}
});
}
@Override
public void errorOccurred(@NotNull final String errorMessage) {
AppUIUtil.invokeOnEdt(() -> {
myTextArea.setForeground(XDebuggerUIConstants.ERROR_MESSAGE_ATTRIBUTES.getFgColor());
myTextArea.setText(errorMessage);
});
}
private void setObsolete() {
myObsolete.set(true);
}
@Override
public boolean isObsolete() {
return myObsolete.get();
}
}
@Nullable
public static String getNodeRawValue(@NotNull XValueNodeImpl valueNode) {
String res = null;

View File

@@ -0,0 +1,23 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.xdebugger.impl.ui.visualizedtext
import com.intellij.openapi.fileTypes.FileTypes
import com.intellij.openapi.util.NlsSafe
import com.intellij.xdebugger.XDebuggerBundle
import com.intellij.xdebugger.ui.TextValueVisualizer
import com.intellij.xdebugger.ui.VisualizedContentTab
// It's not registered as an extension, added explicitly as the last visualizer.
internal object FallbackTextVisualizer : TextValueVisualizer {
override fun visualize(value: @NlsSafe String): List<VisualizedContentTab> =
listOf(object : TextBasedContentTab() {
override val name
get() = XDebuggerBundle.message("xdebugger.visualized.text.name.raw")
override val id
get() = FallbackTextVisualizer::class.qualifiedName!!
override fun formatText() =
value
override val fileType
get() = FileTypes.PLAIN_TEXT
})
}

View File

@@ -0,0 +1,20 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.xdebugger.impl.ui.visualizedtext
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.project.Project
import com.intellij.xdebugger.impl.ui.DebuggerUIUtil
import com.intellij.xdebugger.ui.VisualizedContentTab
/**
* Simple tab that displays the optionally [formatted][formatText] text
* using [fileType]-based highlighting.
*/
abstract class TextBasedContentTab : VisualizedContentTab {
protected abstract fun formatText(): String
protected abstract val fileType: FileType
override fun createComponent(project: Project) =
DebuggerUIUtil.createTextViewer(formatText(), project, fileType).component
}

View File

@@ -0,0 +1,220 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.xdebugger.impl.ui.visualizedtext
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.diagnostic.Attachment
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.DimensionService
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.wm.WindowManager
import com.intellij.ui.AppUIUtil
import com.intellij.ui.ScreenUtil
import com.intellij.ui.components.JBTabbedPane
import com.intellij.xdebugger.frame.XFullValueEvaluator
import com.intellij.xdebugger.impl.ui.*
import com.intellij.xdebugger.ui.TextValueVisualizer
import com.intellij.xdebugger.ui.VisualizedContentTab
import java.awt.CardLayout
import java.awt.Dimension
import java.awt.Font
import java.awt.Rectangle
import java.awt.event.MouseEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JComponent
import javax.swing.JPanel
import kotlin.math.max
private const val SELECTED_TAB_KEY_PREFIX = "DEBUGGER_VISUALIZED_TEXT_SELECTED_TAB#"
private val logger = Logger.getInstance(VisualizedTextPopup.javaClass)
/**
* Provides tools to show a text-like value that might be formatted for better readability (JSON, XML, HTML, etc.).
*/
internal object VisualizedTextPopup {
fun showValuePopup(event: MouseEvent, project: Project, editor: Editor?, component: JComponent, cancelCallback: Runnable?) {
var size = DimensionService.getInstance().getSize(DebuggerUIUtil.FULL_VALUE_POPUP_DIMENSION_KEY, project)
if (size == null) {
val frameSize = WindowManager.getInstance().getFrame(project)!!.size
size = Dimension(frameSize.width / 2, frameSize.height / 2)
}
component.preferredSize = size
val popup = DebuggerUIUtil.createValuePopup(project, component, cancelCallback)
if (editor == null) {
val bounds = Rectangle(event.locationOnScreen, size)
ScreenUtil.fitToScreenVertical(bounds, 5, 5, true)
if (size.width != bounds.width || size.height != bounds.height) {
size = bounds.size
component.preferredSize = size
}
popup.showInScreenCoordinates(event.component, bounds.location)
}
else {
popup.showInBestPositionFor(editor)
}
}
fun evaluateAndShowValuePopup(evaluator: XFullValueEvaluator, event: MouseEvent, project: Project, editor: Editor?) {
if (evaluator is CustomComponentEvaluator) {
return evaluator.show(event, project, editor)
}
val panel = TextPanel(project)
val callback = EvaluationCallback(project, panel)
showValuePopup(event, project, editor, panel, callback::setObsolete)
evaluator.startEvaluation(callback) // to make it really cancellable
}
private class TextPanel(private val project: Project) : JPanel(CardLayout()) {
init {
showOnlyText(XDebuggerUIConstants.getEvaluatingExpressionMessage())
}
fun showOnlyText(value: String, format: (TextViewer) -> Unit = {}) {
val textArea = DebuggerUIUtil.createTextViewer(value, project)
format(textArea)
removeAll()
add(textArea)
revalidate()
repaint()
}
}
private class EvaluationCallback(private val project: Project, private val panel: TextPanel) : XFullValueEvaluator.XFullValueEvaluationCallback {
private val obsolete = AtomicBoolean(false)
private var lastFullValueHashCode: Int? = null
override fun evaluated(fullValue: String, font: Font?) {
// This code is not expected to be called multiple times, but it is actually called in the case of huge Java string.
// 1. NodeDescriptorImpl.updateRepresentation() calls ValueDescriptorImpl.calcRepresentation() and it calls labelChanged()
// 2. NodeDescriptorImpl.updateRepresentation() also directly calls labelChanged()
// Double visualization spoils statistics and wastes the resources.
// Try to prevent it by a simple hash code check.
if (fullValue.hashCode() == lastFullValueHashCode) return
lastFullValueHashCode = fullValue.hashCode()
AppUIUtil.invokeOnEdt {
try {
panel.removeAll()
panel.add(createComponent(fullValue))
panel.revalidate()
panel.repaint()
}
catch (e: Exception) {
errorOccurred(e.toString())
}
}
}
override fun errorOccurred(errorMessage: String) {
AppUIUtil.invokeOnEdt {
panel.showOnlyText(errorMessage) {
it.foreground = XDebuggerUIConstants.ERROR_MESSAGE_ATTRIBUTES.fgColor
}
}
}
fun setObsolete() {
obsolete.set(true)
}
override fun isObsolete(): Boolean {
return obsolete.get()
}
private fun createComponent(fullValue: String): JComponent {
val tabs = collectVisualizedTabs(fullValue)
assert(tabs.isNotEmpty()) { "at least one raw tab is expected to be provided by fallback visualizer" }
if (tabs.size > 1) {
try {
return createTabbedPane(tabs, fullValue)
}
catch (e: Exception) {
logger.error("failed to visualize value", e, Attachment("value.txt", fullValue))
// Fallback to the default visualizer, which provided the last tab.
}
}
return tabs.last()
.also { it.onShown(project, firstTime = true) }
.createComponent(project)
}
private fun createTabbedPane(tabs: List<VisualizedContentTab>, fullValue: String): JComponent {
assert(tabs.isNotEmpty())
val panel = JBTabbedPane()
for (tab in tabs) {
val component = try {
tab.createComponent(project)
}
catch (e: Throwable) {
// It's not easy to recover after missing a tab, so we throw and catch above.
throw Exception("failed to create visualized component (${tab.id})", e)
}
panel.addTab(tab.name, component)
}
// We try to make it content-specific by remembering separate value for every set of tabs.
// E.g., it allows remembering that in the group HTML+XML+RAW user prefers HTML, and in the group HTML+MARKDOWN+RAW -- MARKDOWN.
val selectedTabKey = SELECTED_TAB_KEY_PREFIX + tabs.map { it.id }.sorted().joinToString("#")
val alreadyShownTabs = mutableSetOf<VisualizedContentTab>()
fun onTabShown() {
val idx = panel.selectedIndex
if (idx < 0 || idx >= tabs.size) return
val selectedTab = tabs[idx]
PropertiesComponent.getInstance().setValue(selectedTabKey, selectedTab.id)
val firstTime = alreadyShownTabs.add(selectedTab)
selectedTab.onShown(project, firstTime)
}
val savedSelectedTabId = PropertiesComponent.getInstance().getValue(selectedTabKey)
val selectedIndex = max(0, tabs.indexOfFirst { it.id == savedSelectedTabId })
panel.selectedIndex = selectedIndex
onTabShown() // call it manually, because change listener is triggered only if selectedIndex > 0
panel.model.addChangeListener { onTabShown() }
return panel
}
}
private fun collectVisualizedTabs(fullValue: String): List<VisualizedContentTab> {
return TextValueVisualizer.EP.extensionList.flatMap { viz ->
try {
viz.visualize(fullValue)
}
catch (t: Throwable) {
logger.error("failed to visualize value ($viz)", t, Attachment("value.txt", fullValue))
emptyList()
}
} +
// Explicitly add the fallback raw visualizer to make it the last one.
FallbackTextVisualizer.visualize(fullValue)
}
fun isVisualizable(fullValue: String): Boolean {
// text with line breaks would be nicely rendered by the raw visualizer
return StringUtil.containsLineBreak(fullValue) ||
TextValueVisualizer.EP.extensionList.any { viz ->
try {
viz.canVisualize(fullValue)
}
catch (t: Throwable) {
logger.error("failed to check visualization of value ($viz)", t, Attachment("value.txt", fullValue))
false
}
}
}
}