From c919897cfcfbfee55ebb4dcd4f4ee779eab2e1ba Mon Sep 17 00:00:00 2001 From: Benoit Lubek Date: Wed, 8 Nov 2023 10:44:41 +0100 Subject: [PATCH] [IJ Plugin] Cache viewer: add cache size to selector (#5357) --- .../src/main/graphql/operations.graphql | 2 +- .../normalizedcache/NormalizedCache.kt | 20 +- .../NormalizedCacheToolWindowFactory.kt | 304 ++---------------- .../normalizedcache/PullFromDeviceDialog.kt | 2 +- .../ApolloDebugNormalizedCacheProvider.kt | 2 +- .../DatabaseNormalizedCacheProvider.kt | 2 +- .../normalizedcache/ui/FieldTreeTable.kt | 168 ++++++++++ .../normalizedcache/ui/FieldTreeTableModel.kt | 48 +++ .../ui/FilterHighlightTableCellRenderer.kt | 29 ++ .../ui/RecordSearchTextField.kt | 84 +++++ .../normalizedcache/ui/RecordTable.kt | 71 ++++ .../normalizedcache/ui/RecordTableModel.kt | 47 +++ .../messages/ApolloBundle.properties | 2 + .../src/androidMain/resources/schema.graphqls | 2 +- .../debugserver/internal/graphql/GraphQL.kt | 2 +- 15 files changed, 502 insertions(+), 283 deletions(-) create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTable.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTableModel.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FilterHighlightTableCellRenderer.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordSearchTextField.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTable.kt create mode 100644 intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTableModel.kt diff --git a/intellij-plugin/src/main/graphql/operations.graphql b/intellij-plugin/src/main/graphql/operations.graphql index d776e454597..b52fad5c5a6 100644 --- a/intellij-plugin/src/main/graphql/operations.graphql +++ b/intellij-plugin/src/main/graphql/operations.graphql @@ -16,7 +16,7 @@ query GetNormalizedCache($apolloClientId: ID!, $normalizedCacheId: ID!) { displayName records { key - size + sizeInBytes fields } } diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCache.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCache.kt index 170db4f3671..52a11203786 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCache.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCache.kt @@ -6,7 +6,7 @@ data class NormalizedCache( data class Record( val key: String, val fields: List, - val size: Int, + val sizeInBytes: Int, ) data class Field( @@ -27,12 +27,16 @@ data class NormalizedCache( } fun sorted() = NormalizedCache( - records.sortedWith { o1, o2 -> - when { - o1.key == "QUERY_ROOT" -> -1 - o2.key == "QUERY_ROOT" -> 1 - else -> o1.key.compareTo(o2.key, ignoreCase = true) - } - } + records.sortedWith(RecordKeyComparator) ) + + companion object { + val RecordKeyComparator = Comparator { o1, o2 -> + when { + o1.key == "QUERY_ROOT" -> -1 + o2.key == "QUERY_ROOT" -> 1 + else -> o1.key.compareTo(o2.key, ignoreCase = true) + } + } + } } diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCacheToolWindowFactory.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCacheToolWindowFactory.kt index 55935dd0dbd..64ce7b56290 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCacheToolWindowFactory.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/NormalizedCacheToolWindowFactory.kt @@ -3,23 +3,17 @@ package com.apollographql.ijplugin.normalizedcache import com.apollographql.ijplugin.ApolloBundle import com.apollographql.ijplugin.apollodebugserver.ApolloDebugClient import com.apollographql.ijplugin.apollodebugserver.normalizedCacheSimpleName -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.BooleanValue -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.CompositeValue -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.ListValue -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.Null -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.NumberValue -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.Reference -import com.apollographql.ijplugin.normalizedcache.NormalizedCache.FieldValue.StringValue import com.apollographql.ijplugin.normalizedcache.provider.ApolloDebugNormalizedCacheProvider import com.apollographql.ijplugin.normalizedcache.provider.DatabaseNormalizedCacheProvider +import com.apollographql.ijplugin.normalizedcache.ui.FieldTreeTable +import com.apollographql.ijplugin.normalizedcache.ui.RecordSearchTextField +import com.apollographql.ijplugin.normalizedcache.ui.RecordTable import com.apollographql.ijplugin.telemetry.TelemetryEvent import com.apollographql.ijplugin.telemetry.telemetryService import com.apollographql.ijplugin.util.logw import com.apollographql.ijplugin.util.showNotification import com.intellij.icons.AllIcons import com.intellij.ide.CommonActionsManager -import com.intellij.ide.DefaultTreeExpander -import com.intellij.ide.TreeExpander import com.intellij.ide.dnd.FileCopyPasteUtil import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable @@ -33,49 +27,26 @@ import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.application.invokeLater import com.intellij.openapi.fileChooser.FileChooser import com.intellij.openapi.fileChooser.FileChooserDescriptor -import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.Task import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.JBMenuItem -import com.intellij.openapi.ui.JBPopupMenu import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ex.ToolWindowEx import com.intellij.openapi.wm.ex.ToolWindowManagerListener -import com.intellij.ui.ColoredListCellRenderer -import com.intellij.ui.ColoredTableCellRenderer import com.intellij.ui.OnePixelSplitter import com.intellij.ui.ScrollPaneFactory -import com.intellij.ui.ScrollingUtil -import com.intellij.ui.SearchTextField import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.components.JBList import com.intellij.ui.components.JBPanelWithEmptyText -import com.intellij.ui.components.JBTreeTable import com.intellij.ui.content.ContentFactory import com.intellij.ui.content.ContentManager -import com.intellij.ui.speedSearch.FilteringListModel -import com.intellij.ui.speedSearch.ListWithFilter -import com.intellij.ui.speedSearch.SpeedSearchUtil -import com.intellij.ui.table.JBTable -import com.intellij.ui.treeStructure.treetable.ListTreeTableModel -import com.intellij.ui.treeStructure.treetable.TreeTableModel -import com.intellij.util.ui.ColumnInfo import com.intellij.util.ui.JBUI -import com.intellij.util.ui.ListUiUtil -import com.intellij.util.ui.UIUtil import kotlinx.coroutines.runBlocking -import org.jetbrains.kotlin.utils.addToStdlib.firstIsInstance import org.sqlite.SQLiteException -import java.awt.Color -import java.awt.Component -import java.awt.Cursor -import java.awt.Point -import java.awt.datatransfer.StringSelection +import java.awt.BorderLayout import java.awt.dnd.DnDConstants import java.awt.dnd.DropTarget import java.awt.dnd.DropTargetAdapter @@ -83,20 +54,10 @@ import java.awt.dnd.DropTargetDragEvent import java.awt.dnd.DropTargetDropEvent import java.awt.dnd.DropTargetEvent import java.awt.event.KeyEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import java.io.File -import javax.swing.DefaultListModel import javax.swing.JComponent -import javax.swing.JList -import javax.swing.JTable -import javax.swing.ListSelectionModel +import javax.swing.JPanel import javax.swing.SwingUtilities -import javax.swing.event.PopupMenuEvent -import javax.swing.event.PopupMenuListener -import javax.swing.tree.DefaultMutableTreeNode -import javax.swing.tree.TreePath - class NormalizedCacheToolWindowFactory : ToolWindowFactory, DumbAware, Disposable { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { @@ -144,11 +105,10 @@ class NormalizedCacheWindowPanel( ) : SimpleToolWindowPanel(false, true), Disposable { private lateinit var normalizedCache: NormalizedCache - private lateinit var recordList: JBList - private lateinit var recordListFilter: ListWithFilter + private lateinit var recordTable: RecordTable + private lateinit var recordSearchTextField: RecordSearchTextField - private lateinit var fieldTreeTableModel: ListTreeTableModel - private lateinit var fieldTreeExpander: TreeExpander + private lateinit var fieldTreeTable: FieldTreeTable private val history = History() private var updateHistory = true @@ -265,10 +225,10 @@ class NormalizedCacheWindowPanel( override fun getActionUpdateThread() = ActionUpdateThread.BGT }) addSeparator() - add(CommonActionsManager.getInstance().createExpandAllAction(fieldTreeExpander, this@NormalizedCacheWindowPanel).apply { + add(CommonActionsManager.getInstance().createExpandAllAction(fieldTreeTable.treeExpander, this@NormalizedCacheWindowPanel).apply { getTemplatePresentation().setDescription(ApolloBundle.message("normalizedCacheViewer.toolbar.expandAll")) }) - add(CommonActionsManager.getInstance().createCollapseAllAction(fieldTreeExpander, this@NormalizedCacheWindowPanel).apply { + add(CommonActionsManager.getInstance().createCollapseAllAction(fieldTreeTable.treeExpander, this@NormalizedCacheWindowPanel).apply { getTemplatePresentation().setDescription(ApolloBundle.message("normalizedCacheViewer.toolbar.collapseAll")) }) addSeparator() @@ -298,243 +258,55 @@ class NormalizedCacheWindowPanel( } private fun createNormalizedCacheContent(): JComponent { - val fieldTreeTable = createFieldTreeTable() - val recordList = createRecordList() + fieldTreeTable = FieldTreeTable(::selectRecord) + val recordTableWithFilter = createRecordTableWithFilter() val splitter = OnePixelSplitter(false, .25F).apply { - firstComponent = recordList + firstComponent = recordTableWithFilter secondComponent = fieldTreeTable setResizeEnabled(true) splitterProportionKey = "${NormalizedCacheToolWindowFactory::class.java}.splitterProportionKey" } + SwingUtilities.invokeLater { + recordTable.requestFocusInWindow() + } return splitter } - private fun createRecordList(): JComponent { - val listModel = DefaultListModel().apply { - addAll(normalizedCache.records) - } - recordList = JBList(listModel).apply { - selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION - ScrollingUtil.installActions(this) - ListUiUtil.Selection.installSelectionOnFocus(this) - ListUiUtil.Selection.installSelectionOnRightClick(this) - - cellRenderer = object : ColoredListCellRenderer() { - override fun customizeCellRenderer( - list: JList, - value: NormalizedCache.Record, - index: Int, - selected: Boolean, - hasFocus: Boolean, - ) { - append(value.key) - SpeedSearchUtil.applySpeedSearchHighlighting(list, this, true, selected) - } - } - - addListSelectionListener { - if (selectedValue != null) { - fieldTreeTableModel.setRoot(getRootNodeForRecord(selectedValue)) + private fun createRecordTableWithFilter(): JComponent { + recordTable = RecordTable(normalizedCache).apply { + selectionModel.addListSelectionListener { + if (selectedRow == -1) return@addListSelectionListener + val selectedRowAfterSort = convertRowIndexToModel(selectedRow) + model.getRecordAt(selectedRowAfterSort)?.let { record -> + fieldTreeTable.setRecord(record) if (!updateHistory) { updateHistory = true } else { - history.push(selectedValue) + history.push(record) } } } - - selectedIndex = 0 + selectionModel.setSelectionInterval(0, 0) } - @Suppress("UNCHECKED_CAST") - recordListFilter = ListWithFilter.wrap( - recordList, - ScrollPaneFactory.createScrollPane(recordList), - { it.key }, - true, - true, - true, - ) as ListWithFilter - - // Fix clicking on the search field not focusing the list - recordListFilter.components.firstIsInstance().textEditor.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - recordList.requestFocusInWindow() - } - }) - return recordListFilter - } - - @Suppress("UnstableApiUsage") - private fun createFieldTreeTable(): JComponent { - fieldTreeTableModel = ListTreeTableModel( - DefaultMutableTreeNode(), - arrayOf( - object : ColumnInfo(ApolloBundle.message("normalizedCacheViewer.fields.column.key")) { - override fun getColumnClass() = TreeTableModel::class.java - override fun valueOf(item: Unit) = Unit - }, - object : ColumnInfo(ApolloBundle.message("normalizedCacheViewer.fields.column.value")) { - override fun getColumnClass() = NormalizedCache.Field::class.java - override fun valueOf(item: NormalizedCacheFieldTreeNode) = item.field - }, - ), - ) - - val treeTable = object : JBTreeTable(fieldTreeTableModel) { - override fun getPathBackground(path: TreePath, row: Int): Color? { - return if (row % 2 == 0) { - UIUtil.getDecoratedRowColor() - } else { - null - } - } - }.apply { - columnProportion = .8F - setDefaultRenderer(NormalizedCache.Field::class.java, object : ColoredTableCellRenderer() { - override fun customizeCellRenderer(table: JTable, value: Any?, selected: Boolean, hasFocus: Boolean, row: Int, column: Int) { - value as NormalizedCache.Field - when (val v = value.value) { - is StringValue -> append("\"${v.value}\"") - is NumberValue -> append(v.value.toString()) - is BooleanValue -> append(v.value.toString()) - is ListValue -> append(when (val size = v.value.size) { - 0 -> ApolloBundle.message("normalizedCacheViewer.fields.list.empty") - 1 -> ApolloBundle.message("normalizedCacheViewer.fields.list.single") - else -> ApolloBundle.message("normalizedCacheViewer.fields.list.multiple", size) - }, SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES) - - is CompositeValue -> append("{...}", SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES) - Null -> append("null") - is Reference -> { - append("→ ", SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES) - append(v.key, SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) - } - } - } - }) - - // Handle reference clicks and cursor changes - val mouseAdapter = object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (e.button != MouseEvent.BUTTON1) return - table.cursor = Cursor(Cursor.DEFAULT_CURSOR) - val field = table.getFieldAtPoint(e) ?: return - if (field.value is Reference) { - selectRecord(field.value.key) - } - } - - override fun mouseMoved(e: MouseEvent) { - table.cursor = Cursor( - if (table.getFieldAtPoint(e)?.value is Reference) { - Cursor.HAND_CURSOR - } else { - Cursor.DEFAULT_CURSOR - } - ) - } - - override fun mouseExited(e: MouseEvent) { - table.cursor = Cursor(Cursor.DEFAULT_CURSOR) - } - } - table.addMouseListener(mouseAdapter) - table.addMouseMotionListener(mouseAdapter) - - table.isStriped = true - installPopupMenu(table) + recordSearchTextField = RecordSearchTextField(recordTable) + recordTable.addKeyListener(recordSearchTextField) - fieldTreeExpander = DefaultTreeExpander { tree } + val tableWithFilter = JPanel(BorderLayout()).apply { + add(recordSearchTextField, BorderLayout.NORTH) + add(ScrollPaneFactory.createScrollPane(recordTable), BorderLayout.CENTER) } - return treeTable - } - - private fun installPopupMenu(table: JBTable) { - val popupMenu = object : JBPopupMenu() { - override fun show(invoker: Component, x: Int, y: Int) { - when (table.getFieldAtPoint(x, y)?.value) { - is StringValue, - is NumberValue, - is BooleanValue, - is Reference, - Null, - -> { - super.show(invoker, x, y) - } - - else -> {} - } - } - } - popupMenu.add(JBMenuItem(ApolloBundle.message("normalizedCacheViewer.fields.popupMenu.copyValue")).apply { - addActionListener { - val field = table.getValueAt(table.selectedRow, table.selectedColumn) as NormalizedCache.Field - val valueStr = when (val value = field.value) { - is StringValue -> value.value - is NumberValue -> value.value.toString() - is BooleanValue -> value.value.toString() - is Reference -> value.key - Null -> "null" - else -> return@addActionListener - } - CopyPasteManager.getInstance().setContents(StringSelection(valueStr)) - } - }) - - // Select the row under the popup menu - popupMenu.addPopupMenuListener(object : PopupMenuListener { - override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { - invokeLater { - val row = table.rowAtPoint(SwingUtilities.convertPoint(popupMenu, Point(0, 0), table)) - val column = table.columnAtPoint(SwingUtilities.convertPoint(popupMenu, Point(0, 0), table)) - table.setRowSelectionInterval(row, row) - table.setColumnSelectionInterval(column, column) - } - } - - override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {} - override fun popupMenuCanceled(e: PopupMenuEvent) {} - }) - table.componentPopupMenu = popupMenu - } - - private fun JBTable.getFieldAtPoint(x: Int, y: Int): NormalizedCache.Field? { - val point = Point(x, y) - val row = rowAtPoint(point) - // Add 1 to account for the tree column - val column = columnAtPoint(point) + 1 - return model.getValueAt(row, column) as NormalizedCache.Field? - } - - private fun JBTable.getFieldAtPoint(e: MouseEvent): NormalizedCache.Field? { - return getFieldAtPoint(e.x, e.y) + return tableWithFilter } private fun selectRecord(key: String) { - val record = normalizedCache.records.firstOrNull { it.key == key } ?: return - if (!(recordList.model as FilteringListModel).contains(record)) { + if (!recordTable.model.isRecordShowing(key)) { // Filtered list doesn't contain the record, so clear the filter - recordListFilter.resetFilter() - } - recordList.setSelectedValue(record, true) - } - - private fun getRootNodeForRecord(record: NormalizedCache.Record) = DefaultMutableTreeNode().apply { - addFields(record.fields) - } - - private fun DefaultMutableTreeNode.addFields(fields: List) { - for (field in fields) { - val childNode = NormalizedCacheFieldTreeNode(field) - add(childNode) - when (val value = field.value) { - is ListValue -> childNode.addFields(value.value.mapIndexed { i, v -> NormalizedCache.Field(i.toString(), v) }) - is CompositeValue -> childNode.addFields(value.value) - else -> {} - } + recordSearchTextField.text = "" } + recordTable.selectRecord(key) + recordTable.requestFocusInWindow() } private fun pickFile() { @@ -643,12 +415,6 @@ class NormalizedCacheWindowPanel( }.queue() } - private class NormalizedCacheFieldTreeNode(val field: NormalizedCache.Field) : DefaultMutableTreeNode() { - init { - userObject = field.name - } - } - override fun dispose() { apolloDebugClient?.close() } diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/PullFromDeviceDialog.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/PullFromDeviceDialog.kt index 93bef4bd28f..f4b9773b1f1 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/PullFromDeviceDialog.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/PullFromDeviceDialog.kt @@ -132,7 +132,7 @@ class PullFromDeviceDialog( is ApolloDebugNormalizedCacheNode -> { // Don't close the apolloClient, it will be closed later by the caller apolloDebugClientsToClose.remove(selectedNode.apolloDebugClient) - onApolloDebugCacheSelected(selectedNode.apolloDebugClient, selectedNode.apolloClient.id, selectedNode.normalizedCache.id) + onApolloDebugCacheSelected(selectedNode.apolloDebugClient, selectedNode.apolloClient.id, selectedNode.normalizedCache.id) } } super.doOKAction() diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/provider/ApolloDebugNormalizedCacheProvider.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/provider/ApolloDebugNormalizedCacheProvider.kt index e24be207a7b..1f6f4213733 100644 --- a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/provider/ApolloDebugNormalizedCacheProvider.kt +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/provider/ApolloDebugNormalizedCacheProvider.kt @@ -21,7 +21,7 @@ class ApolloDebugNormalizedCacheProvider : NormalizedCacheProvider { fields = apolloRecord.map { (fieldName, fieldValue) -> Field(fieldName, fieldValue.toFieldValue()) }, - size = apolloRecord.size + sizeInBytes = apolloRecord.sizeInBytes ) } ) diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTable.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTable.kt new file mode 100644 index 00000000000..ef423816bcf --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTable.kt @@ -0,0 +1,168 @@ +package com.apollographql.ijplugin.normalizedcache.ui + +import com.apollographql.ijplugin.ApolloBundle +import com.apollographql.ijplugin.normalizedcache.NormalizedCache +import com.intellij.ide.DefaultTreeExpander +import com.intellij.ide.TreeExpander +import com.intellij.openapi.application.invokeLater +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.JBMenuItem +import com.intellij.openapi.ui.JBPopupMenu +import com.intellij.ui.ColoredTableCellRenderer +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBTreeTable +import com.intellij.util.ui.UIUtil +import java.awt.Color +import java.awt.Component +import java.awt.Cursor +import java.awt.Point +import java.awt.datatransfer.StringSelection +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JTable +import javax.swing.SwingUtilities +import javax.swing.event.PopupMenuEvent +import javax.swing.event.PopupMenuListener +import javax.swing.tree.TreePath + +@Suppress("UnstableApiUsage") +class FieldTreeTable(selectRecord: (String) -> Unit) : JBTreeTable(FieldTreeTableModel()) { + val treeExpander: TreeExpander = DefaultTreeExpander { tree } + + init { + columnProportion = .8F + setDefaultRenderer( + NormalizedCache.Field::class.java, + object : ColoredTableCellRenderer() { + override fun customizeCellRenderer(table: JTable, value: Any?, selected: Boolean, hasFocus: Boolean, row: Int, column: Int) { + value as NormalizedCache.Field + when (val v = value.value) { + is NormalizedCache.FieldValue.StringValue -> append("\"${v.value}\"") + is NormalizedCache.FieldValue.NumberValue -> append(v.value.toString()) + is NormalizedCache.FieldValue.BooleanValue -> append(v.value.toString()) + is NormalizedCache.FieldValue.ListValue -> append(when (val size = v.value.size) { + 0 -> ApolloBundle.message("normalizedCacheViewer.fields.list.empty") + 1 -> ApolloBundle.message("normalizedCacheViewer.fields.list.single") + else -> ApolloBundle.message("normalizedCacheViewer.fields.list.multiple", size) + }, SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES) + + is NormalizedCache.FieldValue.CompositeValue -> append("{...}", SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES) + NormalizedCache.FieldValue.Null -> append("null") + is NormalizedCache.FieldValue.Reference -> { + append("→ ", SimpleTextAttributes.GRAY_ITALIC_ATTRIBUTES) + append(v.key, SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES) + } + } + } + } + ) + + // Handle reference clicks and cursor changes + val mouseAdapter = object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.button != MouseEvent.BUTTON1) return + table.cursor = Cursor(Cursor.DEFAULT_CURSOR) + val field = getFieldAtPoint(e) ?: return + if (field.value is NormalizedCache.FieldValue.Reference) { + selectRecord(field.value.key) + } + } + + override fun mouseMoved(e: MouseEvent) { + table.cursor = Cursor( + if (getFieldAtPoint(e)?.value is NormalizedCache.FieldValue.Reference) { + Cursor.HAND_CURSOR + } else { + Cursor.DEFAULT_CURSOR + } + ) + } + + override fun mouseExited(e: MouseEvent) { + table.cursor = Cursor(Cursor.DEFAULT_CURSOR) + } + } + table.addMouseListener(mouseAdapter) + table.addMouseMotionListener(mouseAdapter) + + table.isStriped = true + + installPopupMenu() + } + + override fun getPathBackground(path: TreePath, row: Int): Color? { + return if (row % 2 == 0) { + UIUtil.getDecoratedRowColor() + } else { + null + } + } + + override fun getModel(): FieldTreeTableModel = super.getModel() as FieldTreeTableModel + + fun setRecord(record: NormalizedCache.Record) { + model.setRecord(record) + } + + private fun installPopupMenu() { + val popupMenu = object : JBPopupMenu() { + override fun show(invoker: Component, x: Int, y: Int) { + when (getFieldAtPoint(x, y)?.value) { + is NormalizedCache.FieldValue.StringValue, + is NormalizedCache.FieldValue.NumberValue, + is NormalizedCache.FieldValue.BooleanValue, + is NormalizedCache.FieldValue.Reference, + NormalizedCache.FieldValue.Null, + -> { + super.show(invoker, x, y) + } + + else -> {} + } + } + } + popupMenu.add(JBMenuItem(ApolloBundle.message("normalizedCacheViewer.fields.popupMenu.copyValue")).apply { + addActionListener { + val field = table.getValueAt(table.selectedRow, table.selectedColumn) as NormalizedCache.Field + val valueStr = when (val value = field.value) { + is NormalizedCache.FieldValue.StringValue -> value.value + is NormalizedCache.FieldValue.NumberValue -> value.value.toString() + is NormalizedCache.FieldValue.BooleanValue -> value.value.toString() + is NormalizedCache.FieldValue.Reference -> value.key + NormalizedCache.FieldValue.Null -> "null" + else -> return@addActionListener + } + CopyPasteManager.getInstance().setContents(StringSelection(valueStr)) + } + }) + + // Select the row under the popup menu + popupMenu.addPopupMenuListener(object : PopupMenuListener { + override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { + invokeLater { + val row = table.rowAtPoint(SwingUtilities.convertPoint(popupMenu, Point(0, 0), table)) + val column = table.columnAtPoint(SwingUtilities.convertPoint(popupMenu, Point(0, 0), table)) + table.setRowSelectionInterval(row, row) + table.setColumnSelectionInterval(column, column) + } + } + + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {} + + override fun popupMenuCanceled(e: PopupMenuEvent) {} + }) + table.componentPopupMenu = popupMenu + } + + private fun getFieldAtPoint(x: Int, y: Int): NormalizedCache.Field? { + val point = Point(x, y) + val row = table.rowAtPoint(point) + // Add 1 to account for the tree column + val column = table.columnAtPoint(point) + 1 + return table.model.getValueAt(row, column) as NormalizedCache.Field? + } + + private fun getFieldAtPoint(e: MouseEvent): NormalizedCache.Field? { + return getFieldAtPoint(e.x, e.y) + } +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTableModel.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTableModel.kt new file mode 100644 index 00000000000..fc2fafbf112 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FieldTreeTableModel.kt @@ -0,0 +1,48 @@ +package com.apollographql.ijplugin.normalizedcache.ui + +import com.apollographql.ijplugin.ApolloBundle +import com.apollographql.ijplugin.normalizedcache.NormalizedCache +import com.intellij.ui.treeStructure.treetable.ListTreeTableModel +import com.intellij.ui.treeStructure.treetable.TreeTableModel +import com.intellij.util.ui.ColumnInfo +import javax.swing.tree.DefaultMutableTreeNode + +class FieldTreeTableModel : ListTreeTableModel( + DefaultMutableTreeNode(), + arrayOf( + object : ColumnInfo(ApolloBundle.message("normalizedCacheViewer.fields.column.key")) { + override fun getColumnClass() = TreeTableModel::class.java + override fun valueOf(item: Unit) = Unit + }, + object : ColumnInfo(ApolloBundle.message("normalizedCacheViewer.fields.column.value")) { + override fun getColumnClass() = NormalizedCache.Field::class.java + override fun valueOf(item: NormalizedCacheFieldTreeNode) = item.field + }, + ), +) { + fun setRecord(record: NormalizedCache.Record) { + setRoot(getRootNodeForRecord(record)) + } + + private fun getRootNodeForRecord(record: NormalizedCache.Record) = DefaultMutableTreeNode().apply { + addFields(record.fields) + } + + private fun DefaultMutableTreeNode.addFields(fields: List) { + for (field in fields) { + val childNode = NormalizedCacheFieldTreeNode(field) + add(childNode) + when (val value = field.value) { + is NormalizedCache.FieldValue.ListValue -> childNode.addFields(value.value.mapIndexed { i, v -> NormalizedCache.Field(i.toString(), v) }) + is NormalizedCache.FieldValue.CompositeValue -> childNode.addFields(value.value) + else -> {} + } + } + } + + class NormalizedCacheFieldTreeNode(val field: NormalizedCache.Field) : DefaultMutableTreeNode() { + init { + userObject = field.name + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FilterHighlightTableCellRenderer.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FilterHighlightTableCellRenderer.kt new file mode 100644 index 00000000000..7c40cab61cf --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/FilterHighlightTableCellRenderer.kt @@ -0,0 +1,29 @@ +package com.apollographql.ijplugin.normalizedcache.ui + +import com.intellij.openapi.util.TextRange +import com.intellij.ui.ColoredTableCellRenderer +import com.intellij.ui.speedSearch.SpeedSearchUtil +import javax.swing.JTable + +class FilterHighlightTableCellRenderer : ColoredTableCellRenderer() { + var filter: String? = null + + override fun customizeCellRenderer( + table: JTable, + value: Any?, + selected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ) { + append(value as String) + + filter?.let { filter -> + if (filter.isEmpty()) return + val matchIndex = value.indexOf(filter, ignoreCase = true) + if (matchIndex == -1) return + val textRange = TextRange(matchIndex, matchIndex + filter.length) + SpeedSearchUtil.applySpeedSearchHighlighting(this, listOf(textRange), selected) + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordSearchTextField.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordSearchTextField.kt new file mode 100644 index 00000000000..d8738f0b0a5 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordSearchTextField.kt @@ -0,0 +1,84 @@ +package com.apollographql.ijplugin.normalizedcache.ui + +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.IdeBorderFactory +import com.intellij.ui.SearchTextField +import com.intellij.ui.SideBorder +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.event.KeyEvent +import java.awt.event.KeyListener +import javax.swing.InputMap +import javax.swing.JTable +import javax.swing.KeyStroke +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +/** + * Allows to delegate typing a filter to this text field when the table is focused, and to delegate up/down events to the table when this + * text field is focused. + * Inspired by [com.intellij.openapi.options.newEditor.SettingsSearch]. + */ +class RecordSearchTextField(private val recordTable: RecordTable) : SearchTextField(false), KeyListener { + init { + border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) + textEditor.border = JBUI.Borders.empty() + textEditor.background = recordTable.background + UIUtil.setBackgroundRecursively(this, recordTable.background) + + addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + recordTable.setFilter(text.trim()) + } + }) + } + + override fun addDocumentListener(listener: DocumentListener?) = super.addDocumentListener(listener) + + private var isDelegatingNow = false + + override fun preprocessEventForTextField(event: KeyEvent): Boolean { + if (!isDelegatingNow) { + val stroke = KeyStroke.getKeyStrokeForEvent(event) + val strokeString = stroke.toString() + // Reset filter on ESC + if ("pressed ESCAPE" == strokeString && text.isNotEmpty()) { + text = "" + return true + } + if (textEditor.isFocusOwner) { + try { + isDelegatingNow = true + val code = stroke.keyCode + val tableNavigation = stroke.modifiers == 0 && (code == KeyEvent.VK_UP || code == KeyEvent.VK_DOWN) + if (tableNavigation || !hasAction(stroke, textEditor.inputMap)) { + recordTable.processKeyEvent(event) + return true + } + } finally { + isDelegatingNow = false + } + } + } + return false + } + + override fun keyPressed(event: KeyEvent) = keyTyped(event) + + override fun keyReleased(event: KeyEvent) = keyTyped(event) + + override fun keyTyped(event: KeyEvent) { + val source = event.source + if (source is JTable) { + if (!hasAction(KeyStroke.getKeyStrokeForEvent(event), source.inputMap)) { + keyEventToTextField(event) + } + } + } + + companion object { + private fun hasAction(stroke: KeyStroke, map: InputMap?): Boolean { + return map != null && map[stroke] != null + } + } +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTable.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTable.kt new file mode 100644 index 00000000000..77cf9197b27 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTable.kt @@ -0,0 +1,71 @@ +package com.apollographql.ijplugin.normalizedcache.ui + +import com.apollographql.ijplugin.normalizedcache.NormalizedCache +import com.intellij.ui.ScrollingUtil +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.NamedColorUtil +import java.awt.Component +import java.awt.event.KeyEvent +import javax.swing.JTable +import javax.swing.ListSelectionModel +import javax.swing.SwingConstants +import javax.swing.table.DefaultTableCellRenderer + +class RecordTable(normalizedCache: NormalizedCache) : JBTable(RecordTableModel(normalizedCache)) { + private val filterHighlightTableCellRenderer = FilterHighlightTableCellRenderer() + + init { + columnModel.getColumn(0).cellRenderer = filterHighlightTableCellRenderer + columnModel.getColumn(1).cellRenderer = object : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable?, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + font = JBFont.small() + return this + } + }.apply { + horizontalAlignment = SwingConstants.RIGHT + foreground = NamedColorUtil.getInactiveTextColor() + } + + columnModel.getColumn(1).maxWidth = 50 + + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + ScrollingUtil.installActions(this) + setShowGrid(false) + setShowColumns(true) + setSelectionMode(ListSelectionModel.SINGLE_SELECTION) + columnSelectionAllowed = false + tableHeader.reorderingAllowed = false + } + + public override fun processKeyEvent(e: KeyEvent) { + super.processKeyEvent(e) + } + + override fun getModel(): RecordTableModel = super.getModel() as RecordTableModel + + fun selectRecord(key: String) { + val index = model.indexOfRecord(key) + if (index != -1) { + val indexAfterSort = convertRowIndexToView(index) + selectionModel.setSelectionInterval(indexAfterSort, indexAfterSort) + scrollRectToVisible(getCellRect(selectedRow, 0, true).apply { + y -= height / 2 + height *= 2 + }) + } + } + + fun setFilter(filter: String) { + model.setFilter(filter) + filterHighlightTableCellRenderer.filter = filter + } +} diff --git a/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTableModel.kt b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTableModel.kt new file mode 100644 index 00000000000..46663c4aaf0 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/com/apollographql/ijplugin/normalizedcache/ui/RecordTableModel.kt @@ -0,0 +1,47 @@ +package com.apollographql.ijplugin.normalizedcache.ui + +import com.apollographql.ijplugin.ApolloBundle +import com.apollographql.ijplugin.normalizedcache.NormalizedCache +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.ui.ColumnInfo +import com.intellij.util.ui.ListTableModel + +class RecordTableModel(private val normalizedCache: NormalizedCache) : ListTableModel( + object : ColumnInfo(ApolloBundle.message("normalizedCacheViewer.records.table.key")) { + override fun valueOf(item: NormalizedCache.Record) = item.key + override fun getComparator(): Comparator = NormalizedCache.RecordKeyComparator + }, + object : ColumnInfo(ApolloBundle.message("normalizedCacheViewer.records.table.size")) { + override fun valueOf(item: NormalizedCache.Record) = StringUtil.formatFileSize(item.sizeInBytes.toLong()) + override fun getComparator(): Comparator = Comparator.comparingInt { it.sizeInBytes } + }, +) { + init { + setItems(normalizedCache.records) + } + + fun getRecordAt(row: Int): NormalizedCache.Record? { + if (row < 0 || row >= rowCount) return null + return getValueAt(row, 0)?.let { selectedKey -> + normalizedCache.records.first { it.key == selectedKey } + } + } + + fun isRecordShowing(key: String): Boolean { + return indexOfRecord(key) != -1 + } + + fun indexOfRecord(key: String): Int { + for (i in 0 until rowCount) { + val value = getValueAt(i, 0) + if (value == key) { + return i + } + } + return -1 + } + + fun setFilter(filter: String) { + setItems(normalizedCache.records.filter { it.key.contains(filter, ignoreCase = true) }) + } +} diff --git a/intellij-plugin/src/main/resources/messages/ApolloBundle.properties b/intellij-plugin/src/main/resources/messages/ApolloBundle.properties index 391dacb695c..e7704028350 100644 --- a/intellij-plugin/src/main/resources/messages/ApolloBundle.properties +++ b/intellij-plugin/src/main/resources/messages/ApolloBundle.properties @@ -169,6 +169,8 @@ normalizedCacheViewer.toolbar.refresh=Refresh normalizedCacheViewer.empty.message=Open or drag and drop a normalized cache .db file. normalizedCacheViewer.empty.openFile=Open file... normalizedCacheViewer.empty.pullFromDevice=Pull from device +normalizedCacheViewer.records.table.key=Key +normalizedCacheViewer.records.table.size=Size normalizedCacheViewer.fields.list.empty=[empty] normalizedCacheViewer.fields.list.single=[1 item] normalizedCacheViewer.fields.list.multiple=[{0} items] diff --git a/libraries/apollo-debug-server/src/androidMain/resources/schema.graphqls b/libraries/apollo-debug-server/src/androidMain/resources/schema.graphqls index e5224d78141..d5a35986076 100644 --- a/libraries/apollo-debug-server/src/androidMain/resources/schema.graphqls +++ b/libraries/apollo-debug-server/src/androidMain/resources/schema.graphqls @@ -19,7 +19,7 @@ type NormalizedCache { type Record { key: String! - size: Int! + sizeInBytes: Int! fields: Fields! } diff --git a/libraries/apollo-debug-server/src/commonMain/kotlin/com/apollographql/apollo3/debugserver/internal/graphql/GraphQL.kt b/libraries/apollo-debug-server/src/commonMain/kotlin/com/apollographql/apollo3/debugserver/internal/graphql/GraphQL.kt index add486149e3..a274b66c5a9 100644 --- a/libraries/apollo-debug-server/src/commonMain/kotlin/com/apollographql/apollo3/debugserver/internal/graphql/GraphQL.kt +++ b/libraries/apollo-debug-server/src/commonMain/kotlin/com/apollographql/apollo3/debugserver/internal/graphql/GraphQL.kt @@ -118,7 +118,7 @@ internal class GraphQLRecord( fun fields() = record.fields - fun size() = record.sizeInBytes + fun sizeInBytes() = record.sizeInBytes } @ApolloAdapter