diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt index aa3521ce..9b54a3f5 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt @@ -35,6 +35,7 @@ import io.github.inductiveautomation.kindling.utils.getLogger import io.github.inductiveautomation.kindling.utils.jFrame import io.github.inductiveautomation.kindling.utils.menuShortcutKeyMaskEx import io.github.inductiveautomation.kindling.utils.render +import io.github.inductiveautomation.kindling.utils.systemClipboard import io.github.inductiveautomation.kindling.utils.traverseChildren import net.miginfocom.layout.PlatformDefaults import net.miginfocom.layout.UnitValue @@ -51,7 +52,6 @@ import java.awt.Menu import java.awt.MenuItem import java.awt.PopupMenu import java.awt.Taskbar -import java.awt.Toolkit import java.awt.Window import java.awt.datatransfer.DataFlavor import java.awt.desktop.QuitStrategy @@ -251,9 +251,8 @@ class MainPanel : JPanel(MigLayout("ins 6, fill, hidemode 3")) { Action( name = "Paste ${clipboardTool.title}", ) { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - if (clipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { - val clipString = clipboard.getData(DataFlavor.stringFlavor) as String + if (systemClipboard.isDataFlavorAvailable(DataFlavor.stringFlavor)) { + val clipString = systemClipboard.getData(DataFlavor.stringFlavor) as String openOrError(clipboardTool.title, "clipboard data") { clipboardTool.open(clipString) } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/DetailsPane.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/DetailsPane.kt index 72ff6639..51a2350d 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/DetailsPane.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/DetailsPane.kt @@ -7,11 +7,11 @@ import io.github.inductiveautomation.kindling.internal.DetailsIcon import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.escapeHtml +import io.github.inductiveautomation.kindling.utils.systemClipboard import net.miginfocom.swing.MigLayout import java.awt.Component import java.awt.EventQueue import java.awt.Rectangle -import java.awt.Toolkit import java.awt.datatransfer.StringSelection import javax.swing.JButton import javax.swing.JFileChooser @@ -50,8 +50,7 @@ class DetailsPane(initialEvents: List = emptyList()) : JPanel(MigLayout( description = "Copy to Clipboard", icon = FlatSVGIcon("icons/bx-clipboard.svg"), ) { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(events.toClipboardFormat()), null) + systemClipboard.setContents(StringSelection(events.toClipboardFormat()), null) } private val save = Action( @@ -98,7 +97,7 @@ class DetailsPane(initialEvents: List = emptyList()) : JPanel(MigLayout( if (event.details.isNotEmpty()) { append("  - "$detailPrefix$key = \"$value\"" + "$DETAIL_PREFIX$key = \"$value\"" } append("/>") } @@ -135,7 +134,7 @@ class DetailsPane(initialEvents: List = emptyList()) : JPanel(MigLayout( } } -private const val detailPrefix = "data-" +private const val DETAIL_PREFIX = "data-" class DetailsEditorKit : HTMLEditorKit() { init { @@ -169,7 +168,7 @@ class DetailsEditorKit : HTMLEditorKit() { elem.attributes.attributeNames.asSequence() .filterIsInstance() .associate { rawAttribute -> - rawAttribute.removePrefix(detailPrefix) to elem.attributes.getAttribute(rawAttribute) as String + rawAttribute.removePrefix(DETAIL_PREFIX) to elem.attributes.getAttribute(rawAttribute) as String } return DetailsIcon(details) } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt index 3db030bd..b2926aa5 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Preferences.kt @@ -2,6 +2,7 @@ package io.github.inductiveautomation.kindling.core import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.StyledLabel +import io.github.inductiveautomation.kindling.utils.dismissOnEscape import io.github.inductiveautomation.kindling.utils.jFrame import kotlinx.serialization.KSerializer import kotlinx.serialization.serializer @@ -39,7 +40,7 @@ class Preference( var dependency: Preference<*>? = null var currentValue - get() = Kindling.Preferences[category, this] ?: default + get() = runCatching { Kindling.Preferences[category, this] }.getOrNull() ?: default set(value) { Kindling.Preferences[category, this] = value for (listener in listeners) { @@ -113,6 +114,7 @@ class Preference( val preferencesEditor by lazy { jFrame("Preferences", 600, 800, initiallyVisible = false) { defaultCloseOperation = JFrame.HIDE_ON_CLOSE + dismissOnEscape() val closeButton = JButton("Close").apply { addActionListener { diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/ResultsPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/ResultsPanel.kt index 0a979f6d..83a2484b 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/ResultsPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/generic/ResultsPanel.kt @@ -7,9 +7,9 @@ import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.ReifiedJXTable import io.github.inductiveautomation.kindling.utils.ReifiedLabelProvider.Companion.setDefaultRenderer import io.github.inductiveautomation.kindling.utils.selectedOrAllRowIndices +import io.github.inductiveautomation.kindling.utils.systemClipboard import io.github.inductiveautomation.kindling.utils.toFileSizeLabel import net.miginfocom.swing.MigLayout -import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.io.File import java.util.Base64 @@ -82,8 +82,7 @@ class ResultsPanel : JPanel(MigLayout("ins 0, fill, hidemode 3")) { } } - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(tsv), null) + systemClipboard.setContents(StringSelection(tsv), null) } private val save = Action( diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt index a53e8814..8d480af9 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt @@ -35,28 +35,31 @@ import io.github.inductiveautomation.kindling.utils.escapeHtml import io.github.inductiveautomation.kindling.utils.rowIndices import io.github.inductiveautomation.kindling.utils.selectedRowIndices import io.github.inductiveautomation.kindling.utils.toBodyLine -import io.github.inductiveautomation.kindling.utils.transferTo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import net.miginfocom.swing.MigLayout import org.jdesktop.swingx.JXSearchField import org.jdesktop.swingx.decorator.ColorHighlighter import org.jdesktop.swingx.table.ColumnControlButton.COLUMN_CONTROL_MARKER import java.awt.Desktop import java.awt.Rectangle -import java.nio.file.Files import java.nio.file.Path +import javax.swing.ButtonGroup import javax.swing.JLabel import javax.swing.JMenu import javax.swing.JMenuBar +import javax.swing.JPanel import javax.swing.JPopupMenu +import javax.swing.JRadioButton import javax.swing.ListSelectionModel import javax.swing.SortOrder import javax.swing.UIManager +import kotlin.io.path.createTempFile import kotlin.io.path.inputStream import kotlin.io.path.name import kotlin.io.path.nameWithoutExtension -import kotlin.io.path.outputStream +import kotlin.io.path.writeText class MultiThreadView( val paths: List, @@ -255,8 +258,8 @@ class MultiThreadView( thread!!.id.toString().contains(query) || thread.name.contains(query, ignoreCase = true) || - thread.system != null && thread.system.contains(query, ignoreCase = true) || - thread.scope != null && thread.scope.contains(query, ignoreCase = true) || + (thread.system != null && thread.system.contains(query, ignoreCase = true)) || + (thread.scope != null && thread.scope.contains(query, ignoreCase = true)) || thread.state.name.contains(query, ignoreCase = true) || thread.stacktrace.any { stack -> stack.contains(query, ignoreCase = true) } } @@ -484,8 +487,8 @@ data object MultiThreadViewer : MultiTool, ClipboardTool, PreferenceCategory { } override fun open(data: String): ToolPanel { - val tempFile = Files.createTempFile("kindling", "cb") - data.byteInputStream() transferTo tempFile.outputStream() + val tempFile = createTempFile(prefix = "kindling", suffix = "cb") + tempFile.writeText(data) return open(tempFile) } @@ -505,6 +508,41 @@ data object MultiThreadViewer : MultiTool, ClipboardTool, PreferenceCategory { }, ) + val DefaultDiffView: Preference = preference( + name = "Default Diff View", + default = DiffViewPreference.UNIFIED, + editor = { + JPanel(MigLayout("ins 0")).apply { + background = null + + val unifiedOption = JRadioButton( + Action("Unified", selected = currentValue == DiffViewPreference.UNIFIED) { + currentValue = DiffViewPreference.UNIFIED + }, + ) + + val sideBySideOption = JRadioButton( + Action("Side-by-side", selected = currentValue == DiffViewPreference.SIDE_BY_SIDE) { + currentValue = DiffViewPreference.SIDE_BY_SIDE + }, + ) + + ButtonGroup().apply { + add(unifiedOption) + add(sideBySideOption) + } + + add(unifiedOption) + add(sideBySideOption) + } + }, + ) + + enum class DiffViewPreference { + UNIFIED, + SIDE_BY_SIDE, + } + override val displayName = "Thread View" - override val preferences = listOf(ShowNullThreads, ShowEmptyValues) + override val preferences = listOf(ShowNullThreads, ShowEmptyValues, DefaultDiffView) } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt index 22c1d1ca..21b51c26 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadComparisonPane.kt @@ -12,12 +12,16 @@ import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.ShowEmpty import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.ShowNullThreads import io.github.inductiveautomation.kindling.thread.model.Thread import io.github.inductiveautomation.kindling.thread.model.ThreadLifespan +import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.ScrollingTextPane import io.github.inductiveautomation.kindling.utils.add +import io.github.inductiveautomation.kindling.utils.attachPopupMenu +import io.github.inductiveautomation.kindling.utils.dismissOnEscape import io.github.inductiveautomation.kindling.utils.escapeHtml import io.github.inductiveautomation.kindling.utils.getAll import io.github.inductiveautomation.kindling.utils.jFrame +import io.github.inductiveautomation.kindling.utils.scrollToTop import io.github.inductiveautomation.kindling.utils.style import io.github.inductiveautomation.kindling.utils.tag import io.github.inductiveautomation.kindling.utils.toBodyLine @@ -28,7 +32,10 @@ import java.awt.Color import java.awt.Font import java.text.DecimalFormat import java.util.EventListener +import javax.swing.JButton +import javax.swing.JCheckBox import javax.swing.JPanel +import javax.swing.JPopupMenu import javax.swing.UIManager import javax.swing.event.EventListenerList import javax.swing.event.HyperlinkEvent @@ -36,7 +43,7 @@ import kotlin.properties.Delegates class ThreadComparisonPane( totalThreadDumps: Int, - private val version: String, + version: String, ) : JPanel(MigLayout("fill, ins 0")) { private val listeners = EventListenerList() @@ -44,12 +51,31 @@ class ThreadComparisonPane( updateData() } - private val threadContainers: List = List(totalThreadDumps) { - ThreadContainer(version).apply { + private val threadContainers: List = List(totalThreadDumps) { i -> + ThreadContainer(i, version).apply { blockerButton.addActionListener { - val blocker = blockerButton.blocker - if (blocker != null) { - fireBlockerSelectedEvent(blocker) + blockerButton.blocker?.let { + fireBlockerSelectedEvent(it) + } + } + + if (i + 1 < totalThreadDumps) { + attachPopupMenu { + val next = threads.getOrNull(i + 1) ?: return@attachPopupMenu null + JPopupMenu().apply { + add( + Action("Compare with next trace") { + jFrame( + title = "Stacktrace Diff", + width = 1000, + height = 600, + ) { + add(ThreadDiffView(threads[i]!!, next)) + dismissOnEscape() + } + }, + ) + } } } } @@ -57,7 +83,19 @@ class ThreadComparisonPane( private val header = HeaderPanel() + private var selectionModel = ThreadSelectionModel() + init { + for (threadContainer in threadContainers) { + threadContainer.addPropertyChangeListener("selected") { e -> + if (e.newValue == true) { + selectionModel.add(threadContainer) + } else { + selectionModel.remove(threadContainer) + } + } + } + ShowNullThreads.addChangeListener { for (container in threadContainers) { container.updateThreadInfo() @@ -92,6 +130,8 @@ class ThreadComparisonPane( header.setThread(it) } + selectionModel = ThreadSelectionModel() + val moreThanOneThread = threads.count { it != null } > 1 val highestCpu = if (moreThanOneThread) { @@ -133,13 +173,31 @@ class ThreadComparisonPane( fun onBlockerSelected(threadId: Int) } - private class HeaderPanel : JPanel(MigLayout("fill, ins 3")) { + private inner class HeaderPanel : JPanel(MigLayout("fill, ins 3")) { private val nameLabel = StyledLabel().apply { isLineWrap = false } + val diffSelection = JButton( + Action("Compare Diffs") { + val (thread1, thread2) = selectionModel.asList() + + jFrame( + title = "Comparing ${thread1.name} from thread dumps ${thread1.index + 1} and ${thread2.index + 1}", + width = 1000, + height = 600, + ) { + add(ThreadDiffView(thread1.thread!!, thread2.thread!!)) + dismissOnEscape() + } + }, + ).apply { + isEnabled = false + } + init { add(nameLabel, "pushx, growx") + add(diffSelection, "east") } fun setThread(thread: Thread) { @@ -166,6 +224,34 @@ class ThreadComparisonPane( } } + private inner class ThreadSelectionModel { + private val internal = ArrayDeque(2) + + init { + updateEnabled() + } + + fun add(container: ThreadContainer) { + if (internal.size == 2) { + val toDeselect = internal.removeFirst() + toDeselect.diffCheckBox.isSelected = false + } + internal.addLast(container) + updateEnabled() + } + + fun remove(container: ThreadContainer) { + internal.remove(container) + updateEnabled() + } + + private fun updateEnabled() { + header.diffSelection.isEnabled = internal.size == 2 + } + + fun asList(): List = internal.sortedBy { it.index } + } + private class BlockerButton : FlatButton() { var blocker: Int? = null set(value) { @@ -196,7 +282,12 @@ class ThreadComparisonPane( } } - var text: String? by scrollingTextPane::text + var text: String? + get() = scrollingTextPane.text + set(value) { + scrollingTextPane.text = value + scrollingTextPane.scrollToTop() + } init { isCollapsed = true @@ -206,7 +297,10 @@ class ThreadComparisonPane( } } - private class ThreadContainer(private val version: String) : JXTaskPaneContainer() { + private class ThreadContainer( + val index: Int, + private val version: String, + ) : JXTaskPaneContainer() { var thread: Thread? by Delegates.observable(null) { _, _, _ -> updateThreadInfo() } @@ -220,8 +314,9 @@ class ThreadComparisonPane( toolTipText = "Open in details popup" addActionListener { thread?.let { - jFrame("Thread ${it.id} Details", 900, 500) { + jFrame("Thread ${it.name} Details", 900, 500) { contentPane = DetailsPane(listOf(it.toDetail(version))) + dismissOnEscape() } } } @@ -229,6 +324,13 @@ class ThreadComparisonPane( val blockerButton = BlockerButton() + val diffCheckBox = JCheckBox("Diff").apply { + isVisible = false + addActionListener { + this@ThreadContainer.firePropertyChange("selected", !isSelected, isSelected) + } + } + private val monitors = DetailContainer("Locked Monitors") private val synchronizers = DetailContainer("Synchronizers") private val stacktrace = DetailContainer("Stacktrace").apply { @@ -240,6 +342,7 @@ class ThreadComparisonPane( JPanel(MigLayout("fill, ins 5, hidemode 3")).apply { add(detailsButton) add(titleLabel, "push, grow, gapleft 8") + add(diffCheckBox) add(blockerButton) }, ) @@ -329,6 +432,11 @@ class ThreadComparisonPane( isSpecial = highlightStacktrace } } + + diffCheckBox.apply { + isSelected = false + isVisible = !thread?.stacktrace.isNullOrEmpty() + } } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadDiffView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadDiffView.kt new file mode 100644 index 00000000..5a9ed86e --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/ThreadDiffView.kt @@ -0,0 +1,341 @@ +package io.github.inductiveautomation.kindling.thread + +import io.github.inductiveautomation.kindling.core.Kindling.Preferences.UI.Theme +import io.github.inductiveautomation.kindling.core.Theme.Companion.theme +import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.DefaultDiffView +import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.DiffViewPreference +import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.DiffViewPreference.SIDE_BY_SIDE +import io.github.inductiveautomation.kindling.thread.MultiThreadViewer.DiffViewPreference.UNIFIED +import io.github.inductiveautomation.kindling.thread.model.Thread +import io.github.inductiveautomation.kindling.utils.Action +import io.github.inductiveautomation.kindling.utils.FlatScrollPane +import io.github.inductiveautomation.kindling.utils.attachPopupMenu +import io.github.inductiveautomation.kindling.utils.configureCellRenderer +import io.github.inductiveautomation.kindling.utils.diff.Diff +import io.github.inductiveautomation.kindling.utils.diff.Difference +import io.github.inductiveautomation.kindling.utils.scrollToTop +import io.github.inductiveautomation.kindling.utils.systemClipboard +import net.miginfocom.swing.MigLayout +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea +import org.fife.ui.rtextarea.RTextScrollPane +import java.awt.Color +import java.awt.Desktop +import java.awt.Font +import java.awt.datatransfer.StringSelection +import javax.swing.BorderFactory +import javax.swing.JButton +import javax.swing.JComboBox +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JPopupMenu +import javax.swing.JScrollPane +import javax.swing.JTextArea +import javax.swing.UIManager +import kotlin.io.path.createTempFile +import kotlin.io.path.writeLines + +class ThreadDiffView( + first: Thread, + second: Thread, +) : JPanel(MigLayout("fill, ins 10, hidemode 3")) { + /* + Main data object to get all relevant diff information, lists, etc. + Even though this is running on the EDT, + in my testing it's fast enough to not matter for any normal stacktrace length + */ + private val difference = Difference.of(first.stacktrace, second.stacktrace) + + @Suppress("EnumValuesSoftDeprecate") + private val viewCombo = JComboBox(DiffViewPreference.values()).apply { + selectedItem = DefaultDiffView.currentValue + + configureCellRenderer { _, value, _, _, _ -> + text = when (value) { + UNIFIED -> "Unified" + SIDE_BY_SIDE -> "Side-by-Side" + null -> null + } + } + + addItemListener { + unified.isVisible = selectedItem == UNIFIED + sideBySide.isVisible = selectedItem == SIDE_BY_SIDE + } + } + + private val openInExternalEditor = JButton( + Action("Open in External Editor") { + val desktop = Desktop.getDesktop() + + listOf( + createTempFile( + prefix = "stack-${first.id}-${first.name}-original-", + suffix = ".txt", + ).writeLines(difference.original), + createTempFile( + prefix = "stack-${second.id}-${second.name}-modified-", + suffix = ".txt", + ).writeLines(difference.modified), + ).forEach { desktop.open(it.toFile()) } + }, + ) + + private val header = JLabel( + "Showing ${difference.additions.size} Additions and ${difference.deletions.size} Deletions", + ).apply { + putClientProperty("FlatLaf.styleClass", "h4") + } + + private val sideBySide = SideBySideView(difference).apply { + isVisible = DefaultDiffView.currentValue == SIDE_BY_SIDE + } + + private val unified = UnifiedView(difference.unifiedDiffList, difference.columnCount).apply { + isVisible = DefaultDiffView.currentValue == UNIFIED + } + + init { + add(header) + add(viewCombo, "align right") + add(openInExternalEditor, "align right, wrap") + add(unified, "dock center, span") + add(sideBySide, "dock center, span") + } +} + +private val Difference.columnCount: Int + get() = unifiedDiffList.maxOf { it.value.length } + +private val addBackground: Color + get() = if (Theme.currentValue.isDark) Color(9, 230, 100, 40) else Color(0xE6FFEC) + +private val delBackground: Color + get() = if (Theme.currentValue.isDark) Color(255, 106, 70, 40) else Color(0xFFEBE9) + +private val monospaced = Font(Font.MONOSPACED, Font.PLAIN, 12) + +private class UnifiedView( + difference: List>, + columnCount: Int, +) : JPanel(MigLayout("fill, ins 0")) { + private val textArea = RSyntaxTextArea().apply { + highlightCurrentLine = false + isEditable = false + theme = Theme.currentValue + + fun highlight() { + removeAllLineHighlights() + difference.forEachIndexed { i, diff -> + when (diff) { + is Diff.Addition -> addLineHighlight(i, addBackground) + is Diff.Deletion -> addLineHighlight(i, delBackground) + is Diff.NoChange -> Unit + } + } + } + + Theme.addChangeListener { newTheme -> + theme = newTheme + highlight() + } + + text = difference.joinToString("\n") { + it.value.padEnd(columnCount) + } + + highlight() + } + + private val gutterWidth = 9 + + private val gutter = JTextArea( + difference.size, + gutterWidth, + ).apply { + font = monospaced + + text = difference.joinToString("\n") { diff -> + when (diff) { + is Diff.Addition -> "%${gutterWidth - 4}s %${gutterWidth - 6}d".format(diff.key, diff.index + 1) + is Diff.Deletion -> "%-${gutterWidth - 6}d %-${gutterWidth - 4}s".format(diff.index + 1, diff.key) + is Diff.NoChange -> "%-3d %s %3d".format(diff.preIndex!! + 1, "|", diff.postIndex!! + 1) + } + } + + margin = textArea.margin + } + + private val scrollPane = RTextScrollPane(textArea, false).apply { + verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_ALWAYS + horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS + + setRowHeaderView(this@UnifiedView.gutter) + } + + init { + add(scrollPane, "push, grow") + + scrollPane.scrollToTop() + + attachPopupMenu { + JPopupMenu().apply { + add( + Action("Copy to clipboard") { + val gutterLines = gutter.text.split("\n") + + val outputText = gutterLines.zip(difference).joinToString("\n") { (gutter, diff) -> + "$gutter ${diff.value}" + } + systemClipboard.setContents(StringSelection(outputText), null) + }, + ) + } + } + } +} + +private class SideBySideView( + difference: Difference, +) : JPanel(MigLayout("fill, ins 0, gap 0")) { + inner class SplitTextArea( + private val diffList: List>, + private val originalStack: List, + private val highlighter: (Diff) -> Color?, + private val omitFromDisplay: (Diff) -> Boolean, + private val columnCount: Int, + ) : RSyntaxTextArea(diffList.size, columnCount) { + fun highlight() { + removeAllLineHighlights() + diffList.forEachIndexed { i, diff -> + highlighter(diff)?.let { color -> addLineHighlight(i, color) } + } + } + + override fun createPopupMenu() = JPopupMenu().apply { + add( + Action("Copy original stacktrace") { + systemClipboard.setContents(StringSelection(originalStack.joinToString("\n")), null) + }, + ) + } + + init { + theme = Theme.currentValue + isEditable = false + highlightCurrentLine = false + + Theme.addChangeListener { newTheme -> + theme = newTheme + highlight() + } + + text = diffList.joinToString("\n") { diff -> + if (omitFromDisplay(diff)) { + " " + } else { + diff.value.padEnd(columnCount) + } + } + + highlight() + } + } + + private val columnCount = difference.columnCount + + private val leftTextArea = SplitTextArea( + diffList = difference.leftDiffList, + originalStack = difference.original, + highlighter = { if (it is Diff.Deletion) delBackground else null }, + omitFromDisplay = { it is Diff.Addition }, + columnCount = columnCount, + ) + + private val rightTextArea = SplitTextArea( + diffList = difference.rightDiffList, + originalStack = difference.modified, + highlighter = { if (it is Diff.Addition) addBackground else null }, + omitFromDisplay = { it is Diff.Deletion }, + columnCount = columnCount, + ) + + private val gutterWidth = 5 + + private val gutter = JTextArea( + difference.rightDiffList.size, + gutterWidth * 2 + 3, + ).apply { + font = monospaced + + text = difference.leftDiffList.zip(difference.rightDiffList).joinToString("\n") { (left, right) -> + buildString { + when (left) { + is Diff.Addition -> { + append(left.key) + append(" ".repeat(gutterWidth - 1)) + } + + is Diff.Deletion -> { + append(left.key) + append(" ") + append(String.format("%${gutterWidth - 2}d", left.index + 1)) + } + + is Diff.NoChange -> { + append(" ") + append(String.format("%${gutterWidth - 2}d", left.index + 1)) + } + } + append(" | ") + when (right) { + is Diff.Deletion -> { + append(" ".repeat(gutterWidth - 1)) + append(right.key) + } + + is Diff.Addition -> { + append(String.format("%-${gutterWidth - 2}d", right.index + 1)) + append(" ") + append(right.key) + } + + is Diff.NoChange -> { + append(String.format("%-${gutterWidth - 2}d", right.index + 1)) + append(" ") + } + } + } + } + } + + private val leftScrollPane = FlatScrollPane(leftTextArea) { + verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_NEVER + horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS + + // Weird way to switch the scrollbar to the other side. Looks nice and symmetric + setRowHeaderView(verticalScrollBar) + border = BorderFactory.createEmptyBorder() + background = null + } + + private val rightScrollPane = FlatScrollPane(rightTextArea) { + horizontalScrollBar.model = leftScrollPane.horizontalScrollBar.model + verticalScrollBar.model = leftScrollPane.verticalScrollBar.model + verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_ALWAYS + horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS + + setRowHeaderView(gutter) + border = BorderFactory.createEmptyBorder() + background = null + } + + init { + add(leftScrollPane, "push, grow") + add(rightScrollPane, "push, grow") + + border = BorderFactory.createLineBorder(UIManager.getColor("Component.borderColor")) + + leftScrollPane.scrollToTop() + rightScrollPane.scrollToTop() + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt index e77fa7e9..ffd25c08 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt @@ -129,3 +129,7 @@ infix fun InputStream.transferTo(output: OutputStream) { output.use(input::transferTo) } } + +fun Iterator.nextOrNull(): T? { + return if (hasNext()) next() else null +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Swing.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Swing.kt index 7e8383ab..62abc143 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Swing.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Swing.kt @@ -5,12 +5,16 @@ import com.github.weisj.jsvg.SVGDocument import com.github.weisj.jsvg.attributes.ViewBox import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.swing.Swing import org.jdesktop.swingx.prompt.BuddySupport import java.awt.Component import java.awt.Container +import java.awt.Point import java.awt.RenderingHints import java.awt.Toolkit +import java.awt.datatransfer.Clipboard +import java.awt.event.KeyEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.awt.image.BufferedImage @@ -18,8 +22,12 @@ import java.io.File import java.util.EventListener import javax.swing.JComponent import javax.swing.JFileChooser +import javax.swing.JFrame +import javax.swing.JPanel.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT import javax.swing.JPopupMenu +import javax.swing.JScrollPane import javax.swing.JTextField +import javax.swing.KeyStroke import javax.swing.SwingUtilities import javax.swing.UIManager import javax.swing.event.DocumentEvent @@ -34,12 +42,23 @@ val EDT_SCOPE by lazy { CoroutineScope(Dispatchers.Swing) } val menuShortcutKeyMaskEx = Toolkit.getDefaultToolkit().menuShortcutKeyMaskEx +val systemClipboard: Clipboard by lazy { Toolkit.getDefaultToolkit().systemClipboard } + val Document.text: String get() = getText(0, length) -inline fun T.attachPopupMenu( - crossinline menuFn: T.(event: MouseEvent) -> JPopupMenu?, -) { +fun JFrame.dismissOnEscape() { + rootPane.actionMap.put( + "dismiss", + Action { + dispose() + }, + ) + rootPane.getInputMap(WHEN_ANCESTOR_OF_FOCUSED_COMPONENT) + .put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "dismiss") +} + +inline fun T.attachPopupMenu(crossinline menuFn: T.(event: MouseEvent) -> JPopupMenu?) { addMouseListener( object : MouseAdapter() { override fun mousePressed(e: MouseEvent) { @@ -123,6 +142,10 @@ var JTextField.rightBuddy: JComponent? BuddySupport.addRight(buddy, this) } +fun JScrollPane.scrollToTop() = EDT_SCOPE.launch { + viewport.viewPosition = Point(0, 0) +} + @Suppress("FunctionName") fun DocumentAdapter(block: (e: DocumentEvent) -> Unit): DocumentListener = object : DocumentListener { override fun changedUpdate(e: DocumentEvent) = block(e) diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/diff/Diff.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/diff/Diff.kt new file mode 100644 index 00000000..348b9120 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/diff/Diff.kt @@ -0,0 +1,125 @@ +package io.github.inductiveautomation.kindling.utils.diff + +import io.github.inductiveautomation.kindling.utils.nextOrNull + +sealed interface Diff { + val key: String + val value: T + val index: Int + + data class Addition( + override val value: T, + override val index: Int, + ) : Diff { + override val key = "+" + } + + data class Deletion( + override val value: T, + override val index: Int, + ) : Diff { + override val key = "-" + } + + data class NoChange( + override val value: T, + val preIndex: Int?, + val postIndex: Int?, + ) : Diff { + override val key = "|" + + override val index: Int + get() = (preIndex ?: postIndex)!! + } +} + +class Difference private constructor( + val original: List, + val modified: List, + val additions: List>, + val deletions: List>, +) { + val leftDiffList: List> = buildList { + original.mapIndexedTo(this) { index, s -> + deletions.find { it.index == index } ?: Diff.NoChange(s, index, null) + } + + var offset = 0 + additions.forEach { + val existingDeletion = get(it.index) + if (existingDeletion !is Diff.Deletion) { + add(it.index, it) + offset++ + } + } + } + + val rightDiffList: List> = buildList { + modified.mapIndexedTo(this) { index, s -> + additions.find { it.index == index } ?: Diff.NoChange(s, index, null) + } + + deletions.forEach { + val existingAddition = getOrNull(it.index) + + if (existingAddition !is Diff.Addition) { + add(it.index, it) + } + } + } + + val unifiedDiffList: List> = buildList { + val leftList = leftDiffList.iterator() + val rightList = rightDiffList.iterator() + + var leftItem = leftList.nextOrNull() + var rightItem = rightList.nextOrNull() + + while (leftItem != null || rightItem != null) { + while (leftItem is Diff.Addition) { + leftItem = leftList.nextOrNull() + } + + while (rightItem is Diff.Deletion) { + rightItem = rightList.nextOrNull() + } + + when { + leftItem is Diff.NoChange && rightItem is Diff.NoChange -> { + add(Diff.NoChange(leftItem.value, leftItem.index, rightItem.index)) + leftItem = leftList.nextOrNull() + rightItem = rightList.nextOrNull() + } + + leftItem is Diff.Deletion -> { + add(leftItem) + leftItem = leftList.nextOrNull() + } + + rightItem is Diff.Addition -> { + add(rightItem) + rightItem = rightList.nextOrNull() + } + } + } + } + + companion object { + fun > of( + original: List, + modified: List, + equalizer: (U, U) -> Boolean = { l, r -> compareValues(l, r) == 0 }, + ): Difference { + val lcs = LongestCommonSequence.of(original, modified, equalizer) + + val additions = modified.mapIndexedNotNull { index, item -> + if (item in lcs) null else Diff.Addition(item, index) + } + val deletions = original.mapIndexedNotNull { index, item -> + if (item in lcs) null else Diff.Deletion(item, index) + } + + return Difference(original, modified, additions, deletions) + } + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/diff/LongestCommonSequence.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/diff/LongestCommonSequence.kt new file mode 100644 index 00000000..5c8ae900 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/diff/LongestCommonSequence.kt @@ -0,0 +1,57 @@ +package io.github.inductiveautomation.kindling.utils.diff + +import kotlin.math.max + +class LongestCommonSequence> private constructor( + private val a: List, + private val b: List, + private val equalityPredicate: (T, T) -> Boolean, +) { + private val lengthMatrix = Array(a.size + 1) { Array(b.size + 1) { -1 } } + + private fun buildLengthMatrix( + i: Int, + j: Int, + ): Int { + if (i == 0 || j == 0) { + lengthMatrix[i][j] = 0 + return 0 + } + + if (lengthMatrix[i][j] != -1) return lengthMatrix[i][j] + + val result: Int = if (equalityPredicate(a[i - 1], b[j - 1])) { + 1 + buildLengthMatrix(i - 1, j - 1) + } else { + max(buildLengthMatrix(i - 1, j), buildLengthMatrix(i, j - 1)) + } + + lengthMatrix[i][j] = result + return result + } + + fun calculateLcs(): List { + var i = a.size + val j = b.size + buildLengthMatrix(i, j) + + return buildList { + for (n in j.downTo(1)) { + if (lengthMatrix[i][n] == lengthMatrix[i][n - 1]) { + continue + } else { + add(0, b[n - 1]) + i-- + } + } + } + } + + companion object { + fun > of( + a: List, + b: List, + equalizer: (U, U) -> Boolean = { l, r -> compareValues(l, r) == 0 }, + ) = LongestCommonSequence(a, b, equalizer).calculateLcs() + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/xml/logback/LogbackEditor.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/xml/logback/LogbackEditor.kt index 9b89a955..3a992e0c 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/xml/logback/LogbackEditor.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/xml/logback/LogbackEditor.kt @@ -13,12 +13,12 @@ import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.HorizontalSplitPane import io.github.inductiveautomation.kindling.utils.chooseFiles import io.github.inductiveautomation.kindling.utils.debounce +import io.github.inductiveautomation.kindling.utils.systemClipboard import net.miginfocom.swing.MigLayout import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea import org.fife.ui.rsyntaxtextarea.SyntaxConstants import org.fife.ui.rtextarea.RTextScrollPane import org.jdesktop.swingx.autocomplete.AutoCompleteDecorator -import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.awt.event.ItemEvent import java.io.File @@ -152,8 +152,7 @@ class LogbackEditor(file: List) : JPanel(MigLayout("ins 6, fill, hidemod description = "Copy to Clipboard", icon = FlatSVGIcon("icons/bx-clipboard.svg"), ) { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(configData.toXml()), null) + systemClipboard.setContents(StringSelection(configData.toXml()), null) } private val saveXmlAction = Action(