From 1ae9e16a76f5daa89897462bc8d2e1c9f33b54f6 Mon Sep 17 00:00:00 2001 From: Maria Sokolova Date: Sun, 18 Jan 2026 19:30:54 +0100 Subject: [PATCH] IDEA-384931 [debugger]: Migrate ThreadDumpPanel to the Tree instead of the flat list. Space-RevId: 2f699ffd2d5822bb67b528cdd325baa505e5ef66 GitOrigin-RevId: 8ae34e5fb5a540c1089d915f4ac4c216b5160c12 --- .../intellij/unscramble/ThreadDumpPanel.java | 151 +++++++++-------- .../unscramble/ThreadDumpPanelTest.kt | 160 ++++++++++++++++++ 2 files changed, 245 insertions(+), 66 deletions(-) create mode 100644 java/java-tests/testSrc/com/intellij/unscramble/ThreadDumpPanelTest.kt diff --git a/java/java-frontback-impl/src/com/intellij/unscramble/ThreadDumpPanel.java b/java/java-frontback-impl/src/com/intellij/unscramble/ThreadDumpPanel.java index e4fc0ef8daee..df1100ff039f 100644 --- a/java/java-frontback-impl/src/com/intellij/unscramble/ThreadDumpPanel.java +++ b/java/java-frontback-impl/src/com/intellij/unscramble/ThreadDumpPanel.java @@ -34,20 +34,12 @@ import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.wm.IdeFocusManager; import com.intellij.threadDumpParser.ThreadState; -import com.intellij.ui.ColoredListCellRenderer; -import com.intellij.ui.DocumentAdapter; -import com.intellij.ui.EditorNotificationPanel; -import com.intellij.ui.LightColors; -import com.intellij.ui.ListSpeedSearch; -import com.intellij.ui.ScrollPaneFactory; -import com.intellij.ui.SearchTextField; -import com.intellij.ui.SideBorder; -import com.intellij.ui.SimpleTextAttributes; -import com.intellij.ui.SpeedSearchComparator; -import com.intellij.ui.components.JBList; +import com.intellij.ui.*; +import com.intellij.ui.treeStructure.Tree; import com.intellij.util.PlatformIcons; import com.intellij.util.ui.UIUtil; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nls; import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; @@ -57,10 +49,10 @@ import javax.swing.JList; import javax.swing.JPanel; import javax.swing.ListSelectionModel; import javax.swing.event.DocumentEvent; -import javax.swing.event.ListSelectionEvent; -import javax.swing.event.ListSelectionListener; -import java.awt.BorderLayout; -import java.awt.Color; +import javax.swing.event.TreeSelectionEvent; +import javax.swing.event.TreeSelectionListener; +import javax.swing.tree.*; +import java.awt.*; import java.awt.datatransfer.StringSelection; import java.io.File; import java.util.ArrayList; @@ -72,7 +64,7 @@ import java.util.List; * @author Konstantin Bulenkov */ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFoldingPanel { - private final JBList myThreadList; + private final Tree myThreadTree; private final EditorNotificationPanel myNotificationPanel = new EditorNotificationPanel(EditorNotificationPanel.Status.Info); private final List myThreadDump; private final List myMergedThreadDump; @@ -110,11 +102,11 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding myFilterField = createSearchTextField(); myFilterPanel = createFilterPanel(); - myThreadList = createThreadList(consoleView); + myThreadTree = createThreadsTree(consoleView); configureToolbar(project, consoleView, toolbarActions); - updateThreadDumpItemList(); + updateThreadsTree(); Editor editor = CommonDataKeys.EDITOR.getData(DataManager.getInstance().getDataContext(consoleView.getPreferredFocusableComponent())); if (editor != null) { @@ -130,12 +122,17 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding } } + @ApiStatus.Internal + Tree getTree() { + return myThreadTree; + } + private SearchTextField createSearchTextField() { SearchTextField searchTextField = new SearchTextField(); searchTextField.addDocumentListener(new DocumentAdapter() { @Override protected void textChanged(@NotNull DocumentEvent e) { - updateThreadDumpItemList(); + updateThreadsTree(); } }); return searchTextField; @@ -149,38 +146,42 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding return filterPanel; } - private static JBList createThreadList(ConsoleView consoleView) { - JBList threadList = new JBList<>(new DefaultListModel<>()); - threadList.setCellRenderer(new ThreadListCellRenderer()); - threadList.setEmptyText(JavaFrontbackBundle.message("thread.dump.loading.text")); - threadList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - threadList.addListSelectionListener(new ListSelectionListener() { - int currentSelectedIndex = -2; // to avoid multiple expensive invocations of printStackTrace() + private static Tree createThreadsTree(ConsoleView consoleView) { + Tree threadTree = new Tree(); + threadTree.setName("Thread Dump"); + threadTree.setCellRenderer(new ThreadTreeCellRenderer()); + threadTree.setRootVisible(false); + threadTree.getEmptyText().setText(JavaFrontbackBundle.message("thread.dump.loading.text")); + threadTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); + threadTree.addTreeSelectionListener(new TreeSelectionListener() { + DumpItem currentlySelectedItem = null; // to avoid multiple expensive invocations of printStackTrace() @Override - public void valueChanged(ListSelectionEvent e) { - int index = threadList.getSelectedIndex(); - if (index != currentSelectedIndex) { - if (index >= 0) { - DumpItem selection = threadList.getModel().getElementAt(index); + public void valueChanged(TreeSelectionEvent e) { + TreePath path = e.getPath(); + if (threadTree.isPathSelected(path)) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent(); + if (node.getUserObject() instanceof DumpItem selection && selection != currentlySelectedItem) { AnalyzeStacktraceUtil.printStacktrace(consoleView, selection.getStackTrace()); + currentlySelectedItem = selection; } - else { - AnalyzeStacktraceUtil.printStacktrace(consoleView, ""); - } - currentSelectedIndex = index; + } else { + AnalyzeStacktraceUtil.printStacktrace(consoleView, ""); } - threadList.repaint(); + threadTree.repaint(); } }); - ListSpeedSearch - .installOn(threadList, DumpItem::getName) + TreeSpeedSearch + .installOn(threadTree, true, path -> { + var node = (DefaultMutableTreeNode)path.getLastPathComponent(); + return node.getUserObject() instanceof DumpItem item ? item.getName() : ""; + }) .setComparator(new SpeedSearchComparator(false, true)); - return threadList; + return threadTree; } private void configureToolbar(Project project, ConsoleView consoleView, DefaultActionGroup toolbarActions) { FilterAction filterAction = new FilterAction(); - filterAction.registerCustomShortcutSet(ActionManager.getInstance().getAction(IdeActions.ACTION_FIND).getShortcutSet(), myThreadList); + filterAction.registerCustomShortcutSet(ActionManager.getInstance().getAction(IdeActions.ACTION_FIND).getShortcutSet(), myThreadTree); toolbarActions.add(filterAction); toolbarActions.add(new CopyToClipboardAction(project)); toolbarActions.add(new SortThreadsAction()); @@ -197,7 +198,7 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding northPanel.add(myNotificationPanel, BorderLayout.NORTH); northPanel.add(myFilterPanel, BorderLayout.SOUTH); leftPanel.add(northPanel, BorderLayout.NORTH); - leftPanel.add(ScrollPaneFactory.createScrollPane(myThreadList, SideBorder.LEFT | SideBorder.RIGHT), BorderLayout.CENTER); + leftPanel.add(ScrollPaneFactory.createScrollPane(myThreadTree, SideBorder.LEFT | SideBorder.RIGHT), BorderLayout.CENTER); Splitter splitter = new Splitter(false, 0.3f); splitter.setFirstComponent(leftPanel); @@ -208,12 +209,13 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding private void sortAndUpdateThreadDumpItemList() { myThreadDump.sort(currentComparator); myMergedThreadDump.sort(currentComparator); - updateThreadDumpItemList(); + updateThreadsTree(); } - private void updateThreadDumpItemList() { + private void updateThreadsTree() { String text = myFilterPanel.isVisible() ? myFilterField.getText() : ""; - Object selection = myThreadList.getSelectedValue(); + var path = myThreadTree.getSelectionPath(); + var selection = path != null ? (DumpItem)((DefaultMutableTreeNode)path.getLastPathComponent()).getUserObject() : null; int selectedIndex = 0; int index = 0; ArrayList filteredThreadStates = new ArrayList<>(); @@ -228,11 +230,18 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding index++; } } - // Add all of them in a single call, otherwise it works too slow recalculating UI layout. - DefaultListModel model = (DefaultListModel)myThreadList.getModel(); - model.clear(); - model.addAll(filteredThreadStates); + var model = (DefaultTreeModel)myThreadTree.getModel(); + var root = ((DefaultMutableTreeNode)model.getRoot()); + if (root != null) { + root.removeAllChildren(); + + for (DumpItem threadState : filteredThreadStates) { + root.add(new DefaultMutableTreeNode(threadState)); + } + } + model.reload(); + int truncated = useMerged ? myMergedDumpItemsTruncated : myDumpItemsTruncated; if (truncated > 0) { myNotificationPanel.text(JavaFrontbackBundle.message("truncated.dump.notification", threadStates.size())); @@ -241,11 +250,11 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding else { myNotificationPanel.setVisible(false); } - if (!model.isEmpty()) { - myThreadList.setSelectedIndex(selectedIndex); + if (root != null && root.getChildCount() > 0) { + myThreadTree.setSelectionRow(selectedIndex); } - myThreadList.revalidate(); - myThreadList.repaint(); + myThreadTree.revalidate(); + myThreadTree.repaint(); } private static void highlightOccurrences(String filter, Project project, Editor editor) { @@ -263,20 +272,30 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding } } - private static class ThreadListCellRenderer extends ColoredListCellRenderer { - private String iconToolTip; + private static class ThreadTreeCellRenderer extends ColoredTreeCellRenderer { + @Nls private String iconToolTip; @Override - protected void customizeCellRenderer(@NotNull JList list, DumpItem threadState, int index, boolean selected, boolean hasFocus) { - setIcon(threadState.getIcon()); - iconToolTip = threadState.getIconToolTip(); - if (!selected) { - DumpItem selectedThread = list.getSelectedValue(); - setBackground(getBackgroundColor(threadState, selectedThread)); + public void customizeCellRenderer(@NotNull JTree tree, + Object value, + boolean selected, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + var node = (DefaultMutableTreeNode)value; + if (node.getUserObject() instanceof DumpItem dumpItem) { + setIcon((dumpItem).getIcon()); + iconToolTip = dumpItem.getIconToolTip(); + if (selected) { + var selectedNode = (DefaultMutableTreeNode)tree.getLastSelectedPathComponent(); + var selectedThread = (DumpItem)selectedNode.getUserObject(); + setBackground(getBackgroundColor(dumpItem, selectedThread)); + } + SimpleTextAttributes attrs = dumpItem.getAttributes(); + append(dumpItem.getName(), attrs); + append(dumpItem.getStateDesc(), attrs); } - SimpleTextAttributes attrs = threadState.getAttributes(); - append(threadState.getName(), attrs); - append(threadState.getStateDesc(), attrs); } @Override @@ -298,7 +317,7 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding } public void selectStackFrame(int index) { - myThreadList.setSelectedIndex(index); + myThreadTree.setSelectionRow(index); } private final class SortThreadsAction extends DumbAwareAction { @@ -377,7 +396,7 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding IdeFocusManager.getInstance(getEventProject(e)).requestFocus(myFilterField, true); myFilterField.selectText(); } - updateThreadDumpItemList(); + updateThreadsTree(); } } @@ -400,7 +419,7 @@ public final class ThreadDumpPanel extends JPanel implements NoStackTraceFolding @Override public void setSelected(@NotNull AnActionEvent e, boolean state) { UISettings.getInstance().getState().setMergeEqualStackTraces(state); - updateThreadDumpItemList(); + updateThreadsTree(); } } diff --git a/java/java-tests/testSrc/com/intellij/unscramble/ThreadDumpPanelTest.kt b/java/java-tests/testSrc/com/intellij/unscramble/ThreadDumpPanelTest.kt new file mode 100644 index 000000000000..4e43bcc426b6 --- /dev/null +++ b/java/java-tests/testSrc/com/intellij/unscramble/ThreadDumpPanelTest.kt @@ -0,0 +1,160 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.unscramble + +import com.intellij.execution.filters.TextConsoleBuilderFactory +import com.intellij.execution.impl.ConsoleViewImpl +import com.intellij.execution.ui.ConsoleView +import com.intellij.icons.AllIcons +import com.intellij.ide.ui.UISettings +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.NlsSafe +import com.intellij.testFramework.LightPlatformTestCase +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.treeStructure.Tree +import junit.framework.TestCase +import org.jetbrains.annotations.Nls +import java.util.Objects +import javax.swing.Icon + + +class ThreadDumpPanelTest : LightPlatformTestCase() { + private lateinit var threadDumpPanel: ThreadDumpPanel + private lateinit var myConsoleView: ConsoleView + + @Throws(Exception::class) + override fun setUp() { + super.setUp() + val consoleBuilder = TextConsoleBuilderFactory.getInstance().createBuilder(project) + myConsoleView = consoleBuilder.getConsole() + threadDumpPanel = ThreadDumpPanel.createFromDumpItems(project, myConsoleView, DefaultActionGroup(), emptyList()) + } + + @Throws(Exception::class) + override fun tearDown() { + try { + Disposer.dispose(myConsoleView) + } + catch (e: Throwable) { + addSuppressedException(e) + } + finally { + super.tearDown() + } + } + + fun testBasicDump() { + val tree: Tree = threadDumpPanel.tree + val dumpItems = createBasicDump() + UISettings.getInstance().getState().mergeEqualStackTraces = false + threadDumpPanel.addDumpItems(dumpItems, 0, emptyList(), 0) + TestCase.assertEquals("Should show all unmerged items", 6, tree.model.getChildCount(tree.model.root)) + // Select first item + tree.setSelectionRow(0) + (myConsoleView as ConsoleViewImpl).waitAllRequests() + + // Verify stack trace is printed to console + val document = (myConsoleView as ConsoleViewImpl).editor!!.document + val consoleText = document.text + assertTrue("Console should contain stack trace of the 1st item MyCoroutine1", consoleText.contains("at MainKt.foo(Main.kt:161)")) + + tree.setSelectionRow(2) + (myConsoleView as ConsoleViewImpl).waitAllRequests() + + assertTrue("Console should contain stack trace of the 3rs item Thread3", consoleText.contains("boo(Main.kt:1)")) + } + + private fun createBasicDump(): List { + return listOf( + TestDumpItem( + name = "MyCoroutine1", + stateDesc = "RUNNING on thread Thread1 [BlockingEventLoop@3e53c781]", + stackTrace = "at MainKt.foo(Main.kt:161)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:101)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:100)\n" + ), + TestDumpItem( + name = "MyCoroutine2", + stateDesc = "RUNNING on thread Thread2 [BlockingEventLoop@3e53c781]", + stackTrace = "at MainKt.foo(Main.kt:161)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:101)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:100)\n" + ), + TestDumpItem( + name = "MyCoroutine3", + stateDesc = "RUNNING on thread Thread3 [BlockingEventLoop@3e53c781]", + stackTrace = "at MainKt.foo(Main.kt:161)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:101)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:100)\n" + ), + TestDumpItem( + name = "Thread1", + stateDesc = "\"Thread1\" daemon prio=5 tid=0x24 nid=NA runnable", + stackTrace = "at MainKt.isPrime(Main.kt:14)\n" + + "\tat MainKt.foo1(Main.kt:32)\n" + + "\tat MainKt.foo3(Main.kt:21)\n" + + "\tat MainKt.foo4(Main.kt:25)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:104)" + ), + TestDumpItem( + name = "Thread2", + stateDesc = "\"Thread2\" daemon prio=5 tid=0x24 nid=NA runnable", + stackTrace = "at MainKt.isPrime(Main.kt:14)\n" + + "\tat MainKt.foo1(Main.kt:32)\n" + + "\tat MainKt.foo3(Main.kt:21)\n" + + "\tat MainKt.foo4(Main.kt:25)\n" + + "\tat MainKt\$main\$1\$t1\$1\$1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:104)" + ), + TestDumpItem( + name = "Thread3", + stateDesc = "\"Thread3\" daemon prio=5 tid=0x24 nid=NA runnable", + stackTrace = "at MainKt.foo2(_Collections.kt:1915)\n" + + "\tat MainKt\$main\$1\$t2\$1\$1\$1\$1\$1\$1.invokeSuspend(Main.kt:128)\n" + + "\tat MainKt\$main\$1\$t2\$1\$1\$1\$1\$1\$1.invoke(Main.kt:-1)\n" + + "\tat MainKt\$main\$1\$t2\$1\$1\$1\$1\$1\$1.invoke(Main.kt:-1)\n" + + "\tat MainKt.boo(Main.kt:167)\n" + + "\tat MainKt.access\$boo(Main.kt:1)" + ), + ) + } +} + +private class TestDumpItem( + override val name: String, + override val stateDesc: String, + override val stackTrace: @NlsSafe String, +): MergeableDumpItem { + override val interestLevel: Int + get() = stackTrace.count { it == '\n' } + override val icon: Icon + get() = AllIcons.Debugger.ThreadRunning + override val iconToolTip: @Nls String? + get() = null + override val attributes: SimpleTextAttributes + get() = DumpItem.RUNNING_ATTRIBUTES + override val isDeadLocked: Boolean + get() = false + override val awaitingDumpItems: Set + get() = emptySet() + + override val mergeableToken: MergeableToken get() = TestMergeableToken() + + private inner class TestMergeableToken : MergeableToken { + private val comparableStackTrace: String = + stackTrace.substringAfter("\n").replace("<0x\\d+>\\s".toRegex(), "") + + override val item: TestDumpItem get() = this@TestDumpItem + + override fun equals(other: Any?): Boolean { + if (other !is TestMergeableToken) return false + if (this.comparableStackTrace != other.comparableStackTrace) return false + return true + } + + override fun hashCode(): Int { + return Objects.hash( + comparableStackTrace + ) + } + } +} \ No newline at end of file