diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt index aa3521ce..6aa319dc 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/MainPanel.kt @@ -174,6 +174,7 @@ class MainPanel : JPanel(MigLayout("ins 6, fill, hidemode 3")) { private val tabs = object : TabStrip() { init { + name = "MainTabStrip" isVisible = false if (SystemInfo.isMacFullWindowContentSupported) { diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/alarm/AlarmCacheView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/alarm/AlarmCacheView.kt index d4842896..ae63fcfe 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/alarm/AlarmCacheView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/alarm/AlarmCacheView.kt @@ -12,6 +12,7 @@ import io.github.inductiveautomation.kindling.core.DetailsPane import io.github.inductiveautomation.kindling.core.Tool import io.github.inductiveautomation.kindling.core.ToolOpeningException import io.github.inductiveautomation.kindling.core.ToolPanel +import io.github.inductiveautomation.kindling.utils.ColorPalette import io.github.inductiveautomation.kindling.utils.EDT_SCOPE import io.github.inductiveautomation.kindling.utils.FileFilter import io.github.inductiveautomation.kindling.utils.ReifiedJXTable @@ -23,7 +24,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.miginfocom.swing.MigLayout import org.jdesktop.swingx.JXSearchField -import org.jdesktop.swingx.decorator.ColorHighlighter import java.awt.Color import java.nio.file.Path import javax.swing.JLabel @@ -53,14 +53,10 @@ class AlarmCacheView(path: Path) : ToolPanel() { ).apply { alarmStateColors.forEach { (state, colorPalette) -> addHighlighter( - ColorHighlighter( - { _, adapter -> - val viewRow = convertRowIndexToModel(adapter.row) - state == model[viewRow].state - }, - colorPalette.background, - colorPalette.foreground, - ), + colorPalette.toHighLighter { _, adapter -> + val viewRow = convertRowIndexToModel(adapter.row) + state == model[viewRow].state + }, ) } } @@ -153,17 +149,12 @@ class AlarmCacheView(path: Path) : ToolPanel() { } } - private data class AlarmStateColorPalette( - val background: Color, - val foreground: Color, - ) - companion object { - private val alarmStateColors = mapOf( - AlarmState.ActiveAcked to AlarmStateColorPalette(Color(0xAB0000), Color(0xD0D0D0)), - AlarmState.ActiveUnacked to AlarmStateColorPalette(Color(0xEC2215), Color(0xD0D0D0)), - AlarmState.ClearAcked to AlarmStateColorPalette(Color(0xDCDCFE), Color(0x262626)), - AlarmState.ClearUnacked to AlarmStateColorPalette(Color(0x49ABAB), Color(0x262626)), + private val alarmStateColors: Map = mapOf( + AlarmState.ActiveAcked to ColorPalette(Color(0xAB0000), Color(0xD0D0D0)), + AlarmState.ActiveUnacked to ColorPalette(Color(0xEC2215), Color(0xD0D0D0)), + AlarmState.ClearAcked to ColorPalette(Color(0xDCDCFE), Color(0x262626)), + AlarmState.ClearUnacked to ColorPalette(Color(0x49ABAB), Color(0x262626)), ) } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Filtering.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Filtering.kt index 54fdcb71..e56ba8ed 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Filtering.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Filtering.kt @@ -8,7 +8,7 @@ import javax.swing.JComponent import javax.swing.JPopupMenu import javax.swing.event.EventListenerList -fun interface Filter { +fun interface Filter { /** * Return true if this filter should display this item. */ diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt index 4220cd9d..5fa9e8ad 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/Kindling.kt @@ -146,6 +146,14 @@ data object Kindling { }, ) + val HighlightByDefault = preference( + name = "Highlight", + default = true, + editor = { + PreferenceCheckbox("Enable table highlighting by default for multiple log files.") + }, + ) + override val displayName: String = "General" override val serialKey: String = "general" override val preferences: List> = listOf( @@ -154,6 +162,7 @@ data object Kindling { ShowFullLoggerNames, ShowLogTree, UseHyperlinks, + HighlightByDefault, ) } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt index 0a1df585..e213f90e 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/core/ToolPanel.kt @@ -21,7 +21,7 @@ abstract class ToolPanel( layoutConstraints: String = "ins 6, fill, hidemode 3", ) : JPanel(MigLayout(layoutConstraints)), FloatableComponent, PopupMenuCustomizer { abstract override val icon: Icon? - override val tabName: String get() = name + override val tabName: String get() = name ?: this.paramString() override val tabTooltip: String get() = toolTipText override fun customizePopupMenu(menu: JPopupMenu) = Unit @@ -61,7 +61,7 @@ abstract class ToolPanel( } } - @Suppress("ktlint:trailing-comma-on-declaration-site") + @Suppress("ktlint:standard:trailing-comma-on-declaration-site") private enum class ExportFormat( description: String, val extension: String, diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt index f5a6b6a6..2ee52a7b 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/idb/IdbView.kt @@ -2,32 +2,30 @@ package io.github.inductiveautomation.kindling.idb import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.components.FlatTabbedPane.TabType -import io.github.inductiveautomation.kindling.core.Tool +import io.github.inductiveautomation.kindling.core.MultiTool +import io.github.inductiveautomation.kindling.core.Preference +import io.github.inductiveautomation.kindling.core.Preference.Companion.PreferenceCheckbox +import io.github.inductiveautomation.kindling.core.Preference.Companion.preference +import io.github.inductiveautomation.kindling.core.PreferenceCategory import io.github.inductiveautomation.kindling.core.ToolPanel import io.github.inductiveautomation.kindling.idb.generic.GenericView import io.github.inductiveautomation.kindling.idb.metrics.MetricsView import io.github.inductiveautomation.kindling.idb.tagconfig.TagConfigView -import io.github.inductiveautomation.kindling.log.LogPanel -import io.github.inductiveautomation.kindling.log.MDC -import io.github.inductiveautomation.kindling.log.SystemLogEvent +import io.github.inductiveautomation.kindling.log.LogFile +import io.github.inductiveautomation.kindling.log.SystemLogPanel +import io.github.inductiveautomation.kindling.log.SystemLogPanel.Companion.parseLogs import io.github.inductiveautomation.kindling.utils.FileFilter import io.github.inductiveautomation.kindling.utils.SQLiteConnection import io.github.inductiveautomation.kindling.utils.TabStrip -import io.github.inductiveautomation.kindling.utils.executeQuery import io.github.inductiveautomation.kindling.utils.get import io.github.inductiveautomation.kindling.utils.toList -import io.github.inductiveautomation.kindling.utils.toMap +import org.sqlite.SQLiteConnection import java.nio.file.Path import java.sql.Connection -import java.time.Instant import kotlin.io.path.name -class IdbView(val path: Path) : ToolPanel() { - private val connection = SQLiteConnection(path) - - private val tables: List = connection.metaData - .getTables("", "", "", null) - .toList { rs -> rs[3] } +class IdbView(paths: List) : ToolPanel() { + private val data = paths.map(::IdbFileData) private val tabs = TabStrip().apply { trailingComponent = null @@ -38,28 +36,60 @@ class IdbView(val path: Path) : ToolPanel() { } init { - name = path.name - toolTipText = path.toString() - - tabs.addTab( - tabName = "Tables", - component = GenericView(connection), - tabTooltip = null, - select = true, - ) + name = paths.first().name + toolTipText = paths.joinToString("\n") var addedTabs = 0 - for (tool in IdbTool.entries) { - if (tool.supports(tables)) { - tabs.addLazyTab( - tabName = tool.tabName, - ) { - tool.open(connection) + + /* + * Not doing partial subsets of files for now. + * (e.g. Multitool X supports files A and B, Multitool Y supports files C and D) + * i.e. we assume the user is opening multiple IDBs they expect to work together + */ + val supportedMultiTools = MultiIdbTool.entries.filter { tool -> + data.all { tool.supports(it.tables) } + } + + if (supportedMultiTools.isNotEmpty()) { + if (IdbViewer.ShowGenericViewWithMultiTools.currentValue || paths.size == 1) { + for ((path, connection, _) in data) { + tabs.addTab( + tabName = path.name, + component = GenericView(connection), + tabTooltip = null, + select = true, + ) + } + } + for (tool in supportedMultiTools) { + tabs.addLazyTab(tabName = tool.tabName) { tool.open(data) } + } + addedTabs = supportedMultiTools.size + } else { + for ((_, connection) in data) { + tabs.addTab( + tabName = "Tables", + component = GenericView(connection), + tabTooltip = null, + select = true, + ) + } + + for (tool in SingleIdbTool.entries) { + for (idbFile in data) { + if (tool.supports(idbFile.tables)) { + tabs.addLazyTab( + tabName = tool.tabName, + ) { + tool.open(idbFile) + } + addedTabs += 1 + } } - addedTabs += 1 } } - if (addedTabs == 1) { + + if (addedTabs > 0) { tabs.selectedIndex = tabs.indices.last } @@ -70,113 +100,97 @@ class IdbView(val path: Path) : ToolPanel() { override fun removeNotify() { super.removeNotify() - connection.close() + data.forEach { it.connection.close() } } -} -enum class IdbTool { - @Suppress("SqlResolve") - Logs { - override fun supports(tables: List): Boolean = "logging_event" in tables + companion object { + fun Connection.getAllTableNames(): List { + if (this !is SQLiteConnection) return emptyList() + return metaData + .getTables("", "", "", null) + .toList { rs -> rs[3] } + } + } - override fun open(connection: Connection): ToolPanel { - val stackTraces: Map> = connection.executeQuery( - """ - SELECT - event_id, - trace_line - FROM - logging_event_exception - ORDER BY - event_id, - i - """.trimIndent(), - ) - .toMap> { rs -> - val key: Long = rs["event_id"] - val valueList = getOrPut(key, ::mutableListOf) - valueList.add(rs["trace_line"]) - } + internal class IdbFileData(val path: Path) { + val connection = SQLiteConnection(path) + val tables = connection.getAllTableNames() - val mdcKeys: Map> = connection.executeQuery( - """ - SELECT - event_id, - mapped_key, - mapped_value - FROM - logging_event_property - ORDER BY - event_id - """.trimIndent(), - ).toMap> { rs -> - val key: Long = rs["event_id"] - val valueList = getOrPut(key, ::mutableListOf) - valueList += - MDC( - rs["mapped_key"], - rs["mapped_value"], - ) - } + operator fun component1() = path + operator fun component2() = connection + operator fun component3() = tables + } +} - val events = connection.executeQuery( - """ - SELECT - event_id, - timestmp, - formatted_message, - logger_name, - level_string, - thread_name - FROM - logging_event - ORDER BY - timestmp - """.trimIndent(), - ).toList { rs -> - val eventId: Long = rs["event_id"] - SystemLogEvent( - timestamp = Instant.ofEpochMilli(rs["timestmp"]), - message = rs["formatted_message"], - logger = rs["logger_name"], - thread = rs["thread_name"], - level = enumValueOf(rs["level_string"]), - mdc = mdcKeys[eventId].orEmpty(), - stacktrace = stackTraces[eventId].orEmpty(), - ) - } +private sealed interface IdbTool { + fun supports(tables: List): Boolean - return LogPanel(events) - } - }, + fun open(fileData: IdbView.IdbFileData): ToolPanel + + val tabName: String +} + +private enum class SingleIdbTool : IdbTool { Metrics { override fun supports(tables: List): Boolean = "SYSTEM_METRICS" in tables - override fun open(connection: Connection): ToolPanel = MetricsView(connection) + override fun open(fileData: IdbView.IdbFileData): ToolPanel = MetricsView(fileData.connection) }, Images { override fun supports(tables: List): Boolean = "IMAGES" in tables - override fun open(connection: Connection): ToolPanel = ImagesPanel(connection) + override fun open(fileData: IdbView.IdbFileData): ToolPanel = ImagesPanel(fileData.connection) }, TagConfig { override fun supports(tables: List): Boolean = "TAGCONFIG" in tables - override fun open(connection: Connection): ToolPanel = TagConfigView(connection) + override fun open(fileData: IdbView.IdbFileData): ToolPanel = TagConfigView(fileData.connection) override val tabName: String = "Tag Config" }, ; - abstract fun supports(tables: List): Boolean + override val tabName: String = name +} + +private enum class MultiIdbTool : IdbTool { + Logs { + override fun supports(tables: List): Boolean = "logging_event" in tables + override fun open(fileData: List): ToolPanel { + val paths = fileData.map { it.path } + + val logFiles = fileData.map { (_, connection, _) -> + LogFile( + connection.parseLogs().also { connection.close() }, + ) + } - abstract fun open(connection: Connection): ToolPanel + return SystemLogPanel(paths, logFiles) + } + }, + ; - open val tabName: String = name + abstract fun open(fileData: List): ToolPanel + override fun open(fileData: IdbView.IdbFileData): ToolPanel = open(listOf(fileData)) + override val tabName = name } -data object IdbViewer : Tool { +data object IdbViewer : MultiTool, PreferenceCategory { override val serialKey = "idb-viewer" override val title = "SQLite Database" override val description = "SQLite Database (.idb)" override val icon = FlatSVGIcon("icons/bx-hdd.svg") override val filter = FileFilter(description, "idb", "db", "sqlite") - override fun open(path: Path): ToolPanel = IdbView(path) + override fun open(path: Path): ToolPanel = IdbView(listOf(path)) + + override fun open(paths: List): ToolPanel = IdbView(paths) + + override val displayName = "IDB Viewer" + + val ShowGenericViewWithMultiTools: Preference = preference( + name = "Include Generic IDB Tabs with Multiple Files", + default = false, + editor = { + PreferenceCheckbox("Include tabs for Generic IDB browser when opening multiple IDB Files") + }, + ) + + override val preferences: List> = listOf(ShowGenericViewWithMultiTools) } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt index f7299f2c..e698910b 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LevelPanel.kt @@ -3,24 +3,31 @@ package io.github.inductiveautomation.kindling.log import com.formdev.flatlaf.extras.FlatSVGIcon import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.Column +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FilterListPanel import io.github.inductiveautomation.kindling.utils.FilterModel import javax.swing.JPopupMenu -internal class LevelPanel(rawData: List) : FilterListPanel("Levels") { +internal class LevelPanel( + rawData: List, +) : FilterListPanel("Levels"), FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-bar-chart-alt.svg") + override fun setModelData(data: List) { + filterList.setModel(FilterModel.fromRawData(data, filterList.comparator) { it.level?.name }) + } + init { - filterList.setModel(FilterModel(rawData.groupingBy { it.level?.name }.eachCount())) + setModelData(rawData) filterList.selectAll() } - override fun filter(item: LogEvent): Boolean = item.level?.name in filterList.checkBoxListSelectedValues + override fun filter(item: T): Boolean = item.level?.name in filterList.checkBoxListSelectedValues override fun customizePopupMenu( menu: JPopupMenu, - column: Column, - event: LogEvent, + column: Column, + event: T, ) { val level = event.level if ((column == WrapperLogColumns.Level || column == SystemLogColumns.Level) && level != null) { diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt index 70c46553..81fca3d0 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogPanel.kt @@ -24,7 +24,6 @@ import io.github.inductiveautomation.kindling.utils.asActionIcon import io.github.inductiveautomation.kindling.utils.attachPopupMenu import io.github.inductiveautomation.kindling.utils.configureCellRenderer import io.github.inductiveautomation.kindling.utils.debounce -import io.github.inductiveautomation.kindling.utils.isSortedBy import io.github.inductiveautomation.kindling.utils.selectedRowIndices import io.github.inductiveautomation.kindling.utils.toBodyLine import kotlinx.coroutines.CoroutineScope @@ -33,6 +32,7 @@ import kotlinx.coroutines.launch import net.miginfocom.swing.MigLayout import org.jdesktop.swingx.JXSearchField import org.jdesktop.swingx.table.ColumnControlButton.COLUMN_CONTROL_MARKER +import java.awt.BorderLayout import java.util.Vector import javax.swing.BorderFactory import javax.swing.Icon @@ -49,32 +49,31 @@ import io.github.inductiveautomation.kindling.core.Detail as DetailEvent typealias LogFilter = Filter -class LogPanel( +sealed class LogPanel( /** * Pass a **sorted** list of LogEvents, in ascending order. */ - private val rawData: List, + rawData: List, + private val columnList: LogColumnList, ) : ToolPanel("ins 0, fill, hidemode 3") { + protected val rawData: MutableList = rawData.sortedBy(LogEvent::timestamp).toMutableList() + + protected var selectedData: List = rawData + set(value) { + field = value.sortedBy(LogEvent::timestamp) + footer.totalRows = value.size + updateData() + } + init { if (rawData.isEmpty()) { throw ToolOpeningException("Opening an empty log file is pointless") } - if (!rawData.isSortedBy(LogEvent::timestamp)) { - throw ToolOpeningException("Input data must be sorted by timestamp, ascending") - } } - private val totalRows: Int = rawData.size + protected val header = Header() - private val header = Header() - - private val footer = Footer(totalRows) - - private val columnList = if (rawData.first() is SystemLogEvent) { - SystemLogColumns - } else { - WrapperLogColumns - } + private val footer = Footer(selectedData.size) val table = run { val initialModel = createModel(rawData) @@ -85,69 +84,33 @@ class LogPanel( private val tableScrollPane = FlatScrollPane(table) - private val sidebar = FilterSidebar( - LoggerNamePanel(rawData), - LevelPanel(rawData), - if (rawData.first() is SystemLogEvent) { - @Suppress("UNCHECKED_CAST") - MDCPanel(rawData as List) - } else { - null - }, - if (rawData.first() is SystemLogEvent) { - @Suppress("UNCHECKED_CAST") - ThreadPanel(rawData as List) - } else { - null - }, - TimePanel( - rawData, - ), - ) + abstract val sidebar: FilterSidebar - private val details = DetailsPane() + private val sidebarContainer = JPanel(BorderLayout()) - private val filters: List = buildList { - for (panel in sidebar.filterPanels) { - add { event -> - panel.filter(event) || - (header.markedBehavior.selectedItem == "Always Show Marked" && event.marked) - } - } - add { event -> - header.markedBehavior.selectedItem != "Only Show Marked" || event.marked - } - add { event -> - val text = header.search.text - if (text.isNullOrEmpty()) { - true - } else { - when (event) { - is SystemLogEvent -> { - text in event.message || - event.logger.contains(text, ignoreCase = true) || - event.thread.contains(text, ignoreCase = true) || - event.stacktrace.any { stacktrace -> - stacktrace.contains(text, ignoreCase = true) - } || - (header.markedBehavior.selectedItem == "Always Show Marked" && event.marked) - } + protected fun addSidebar(sidebar: FilterSidebar) { + sidebarContainer.add(sidebar, BorderLayout.CENTER) - is WrapperLogEvent -> { - text in event.message || - event.logger.contains(text, ignoreCase = true) || - event.stacktrace.any { stacktrace -> stacktrace.contains(text, ignoreCase = true) } || - (header.markedBehavior.selectedItem == "Always Show Marked" && event.marked) - } - } - } - } + filters.addAll(sidebar) } + private val details = DetailsPane() + + protected val filters = mutableListOf>( + object : Filter { + override fun filter(item: T): Boolean { + return header.markedBehavior.selectedItem != "Only Show Marked" || + item.marked + } + }, + ) + private val dataUpdater = debounce(50.milliseconds, BACKGROUND) { val selectedEvents = table.selectedRowIndices().map { row -> table.model[row].hashCode() } - val filteredData = rawData.filter { event -> - filters.all { filter -> filter.filter(event) } + val filteredData = selectedData.filter { event -> + filters.all { filter -> + filter.filter(event) + } || (header.markedBehavior.selectedItem == "Always Show Marked" && event.marked) } EDT_SCOPE.launch { @@ -166,26 +129,69 @@ class LogPanel( } } - private fun updateData() = dataUpdater() + protected fun updateData() = dataUpdater() fun reset() { - sidebar.filterPanels.forEach(FilterPanel::reset) + sidebar.forEach(FilterPanel<*>::reset) header.search.text = null } - @Suppress("UNCHECKED_CAST") - private fun createModel(rawData: List): LogsModel = when (columnList) { - is WrapperLogColumns -> LogsModel(rawData as List, columnList) - is SystemLogColumns -> LogsModel(rawData as List, columnList) + private fun createModel(rawData: List): LogsModel = LogsModel(rawData, columnList) + + override val icon: Icon = LogViewer.icon + + private fun getNextMarkedIndex(): Int { + val currentSelectionIndex = table.selectionModel.selectedIndices?.lastOrNull() ?: 0 + val markedEvents = table.model.data + .filter { it.marked } + .sortedBy { table.convertRowIndexToView(table.model.data.indexOf(it)) } + val rowIndex = when (markedEvents.size) { + 0 -> -1 + 1 -> table.model.data.indexOf(markedEvents.first()) + else -> { + val nextMarkedEvent = + markedEvents.firstOrNull { event -> + table.convertRowIndexToView(table.model.data.indexOf(event)) > currentSelectionIndex + } + if (nextMarkedEvent == null) { + table.model.data.indexOf(markedEvents.first()) + } else { + table.model.data.indexOf(nextMarkedEvent) + } + } + } + return if (rowIndex != -1) table.convertRowIndexToView(rowIndex) else -1 } - override val icon: Icon? = null + private fun getPrevMarkedIndex(): Int { + val currentSelectionIndex = table.selectionModel.selectedIndices?.firstOrNull() ?: 0 + val markedEvents = table.model.data + .filter { it.marked } + .sortedBy { table.convertRowIndexToView(table.model.data.indexOf(it)) } + val rowIndex = when (markedEvents.size) { + 0 -> -1 + 1 -> table.model.data.indexOf(markedEvents.first()) + else -> { + val prevMarkedEvent = + markedEvents.lastOrNull { event -> + table.convertRowIndexToView(table.model.data.indexOf(event)) < currentSelectionIndex + } + if (prevMarkedEvent == null) { + table.model.data.indexOf(markedEvents.last()) + } else { + table.model.data.indexOf(prevMarkedEvent) + } + } + } + return if (rowIndex != -1) table.convertRowIndexToView(rowIndex) else -1 + } init { + @Suppress("LeakingThis") add( VerticalSplitPane( HorizontalSplitPane( - sidebar, + sidebarContainer, JPanel(MigLayout("ins 0, fill")).apply { add(header, "wrap, growx") add(tableScrollPane, "grow, push") @@ -196,6 +202,7 @@ class LogPanel( ), "wrap, push, grow", ) + @Suppress("LeakingThis") add(footer, "growx, spanx 2") table.apply { @@ -227,7 +234,7 @@ class LogPanel( JPopupMenu().apply { val column = model.columns[convertColumnIndexToModel(colAtPoint)] val event = model[convertRowIndexToModel(rowAtPoint)] - for (filterPanel in sidebar.filterPanels) { + for (filterPanel in sidebar) { filterPanel.customizePopupMenu(this, column, event) } @@ -271,52 +278,6 @@ class LogPanel( } } - fun getNextMarkedIndex(): Int { - val currentSelectionIndex = table.selectionModel.selectedIndices?.lastOrNull() ?: 0 - val markedEvents = table.model.data - .filter { it.marked } - .sortedBy { table.convertRowIndexToView(table.model.data.indexOf(it)) } - val rowIndex = when (markedEvents.size) { - 0 -> -1 - 1 -> table.model.data.indexOf(markedEvents.first()) - else -> { - val nextMarkedEvent = - markedEvents.firstOrNull { event -> - table.convertRowIndexToView(table.model.data.indexOf(event)) > currentSelectionIndex - } - if (nextMarkedEvent == null) { - table.model.data.indexOf(markedEvents.first()) - } else { - table.model.data.indexOf(nextMarkedEvent) - } - } - } - return if (rowIndex != -1) table.convertRowIndexToView(rowIndex) else -1 - } - - fun getPrevMarkedIndex(): Int { - val currentSelectionIndex = table.selectionModel.selectedIndices?.firstOrNull() ?: 0 - val markedEvents = table.model.data - .filter { it.marked } - .sortedBy { table.convertRowIndexToView(table.model.data.indexOf(it)) } - val rowIndex = when (markedEvents.size) { - 0 -> -1 - 1 -> table.model.data.indexOf(markedEvents.first()) - else -> { - val prevMarkedEvent = - markedEvents.lastOrNull { event -> - table.convertRowIndexToView(table.model.data.indexOf(event)) < currentSelectionIndex - } - if (prevMarkedEvent == null) { - table.model.data.indexOf(markedEvents.last()) - } else { - table.model.data.indexOf(prevMarkedEvent) - } - } - } - return if (rowIndex != -1) table.convertRowIndexToView(rowIndex) else -1 - } - header.apply { search.addActionListener { updateData() @@ -350,10 +311,6 @@ class LogPanel( } } - sidebar.filterPanels.forEach { filterPanel -> - filterPanel.addFilterChangeListener(::updateData) - } - ShowFullLoggerNames.addChangeListener { table.model.fireTableDataChanged() } @@ -368,6 +325,12 @@ class LogPanel( } } + override fun customizePopupMenu(menu: JPopupMenu) { + menu.add( + exportMenu { table.model }, + ) + } + private fun ListSelectionModel.updateDetails() { details.events = selectedIndices.filter { isSelectedIndex(it) } @@ -389,13 +352,13 @@ class LogPanel( }, details = when (event) { is SystemLogEvent -> event.mdc.associate { (key, value) -> key to value } - is WrapperLogEvent -> emptyMap() + else -> emptyMap() }, ) } } - private class Header : JPanel(MigLayout("ins 0, fill, hidemode 3")) { + protected class Header : JPanel(MigLayout("ins 0, fill, hidemode 3")) { val search = JXSearchField("") val version: JComboBox = @@ -407,7 +370,7 @@ class LogPanel( } private val versionLabel = JLabel("Version") - val versionPanel = JPanel(MigLayout("fill, ins 0 2 0 2")).apply { + private val versionPanel = JPanel(MigLayout("fill, ins 0 2 0 2")).apply { border = BorderFactory.createTitledBorder("Stacktrace Links") add(versionLabel) add(version, "growy") @@ -424,7 +387,7 @@ class LogPanel( } val markedBehavior = JComboBox(arrayOf("Show All Events", "Only Show Marked", "Always Show Marked")) - val markedPanel = JPanel(MigLayout("fill, ins 0 2 0 2")).apply { + private val markedPanel = JPanel(MigLayout("fill, ins 0 2 0 2")).apply { border = BorderFactory.createTitledBorder("Marking") add(prevMarked) add(nextMarked) @@ -432,7 +395,7 @@ class LogPanel( add(clearMarked) } - val searchPanel = JPanel(MigLayout("fill, ins 0 2 0 2")).apply { + private val searchPanel = JPanel(MigLayout("fill, ins 0 2 0 2")).apply { border = BorderFactory.createTitledBorder("Search") add(search, "grow") } @@ -453,12 +416,19 @@ class LogPanel( } } - private class Footer(val totalRows: Int) : JPanel(MigLayout("ins 2 4 0 4, fill, gap 10")) { + private class Footer(totalRows: Int) : JPanel(MigLayout("ins 2 4 0 4, fill, gap 10")) { var displayedRows = totalRows set(value) { field = value events.text = "Showing $value of $totalRows events" } + + var totalRows: Int = totalRows + set(value) { + field = value + events.text = "Showing $displayedRows of $value events" + } + var selectedRows: IntRange? = null set(value) { field = value diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogTree.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogTree.kt index 987a0fb3..2f9cac0e 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogTree.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LogTree.kt @@ -37,6 +37,16 @@ class LogEventNode( frequency + children.filterIsInstance().sumOf { it.frequency } } } + + override fun equals(other: Any?): Boolean { + if (other == null || other !is LogEventNode) { + return false + } + + return other.userObject == this.userObject + } + + override fun hashCode(): Int = userObject.hashCode() } class RootNode(logEvents: List) : AbstractTreeNode() { diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LoggerNamePanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LoggerNamePanel.kt index 4a9b34d1..a3c05403 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/LoggerNamePanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/LoggerNamePanel.kt @@ -1,7 +1,6 @@ package io.github.inductiveautomation.kindling.log import com.formdev.flatlaf.extras.FlatSVGIcon -import com.jidesoft.swing.CheckBoxList import io.github.inductiveautomation.kindling.core.FilterChangeListener import io.github.inductiveautomation.kindling.core.FilterPanel import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.ShowFullLoggerNames @@ -10,6 +9,7 @@ import io.github.inductiveautomation.kindling.utils.AbstractTreeNode import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.ButtonPanel import io.github.inductiveautomation.kindling.utils.Column +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FilterList import io.github.inductiveautomation.kindling.utils.FilterModel import io.github.inductiveautomation.kindling.utils.FlatScrollPane @@ -20,6 +20,7 @@ import io.github.inductiveautomation.kindling.utils.attachPopupMenu import io.github.inductiveautomation.kindling.utils.collapseAll import io.github.inductiveautomation.kindling.utils.expandAll import io.github.inductiveautomation.kindling.utils.getAll +import io.github.inductiveautomation.kindling.utils.isAllSelected import io.github.inductiveautomation.kindling.utils.selectAll import net.miginfocom.swing.MigLayout import javax.swing.JButton @@ -28,11 +29,13 @@ import javax.swing.JMenuItem import javax.swing.JPanel import javax.swing.JPopupMenu import javax.swing.JScrollPane +import javax.swing.tree.DefaultTreeModel import javax.swing.tree.TreePath -class LoggerNamePanel(private val rawData: List) : - FilterPanel(), - PopupMenuCustomizer { +class LoggerNamePanel(private val rawData: List) : + FilterPanel(), + PopupMenuCustomizer, + FileFilterResponsive { private val isTreeAvailable = rawData.first() is SystemLogEvent private var isTreeMode: Boolean = ShowLogTree.currentValue && isTreeAvailable @@ -51,12 +54,7 @@ class LoggerNamePanel(private val rawData: List) : private var isContextChanging: Boolean = false private val filterList = FilterList(toStringFn = ::getSortKey).apply { - setModel( - FilterModel( - rawData.groupingBy(LogEvent::logger).eachCount(), - ::getSortKey, - ), - ) + setModel(FilterModel.fromRawData(rawData, comparator, ::getSortKey, LogEvent::logger)) selectAll() // Right clicking will check and uncheck an item. We don't want that for this list. @@ -113,7 +111,46 @@ class LoggerNamePanel(private val rawData: List) : private var selectedTreeNodes: Set = emptySet() - override fun filter(item: LogEvent): Boolean = if (isTreeMode) { + @Suppress("unchecked_cast") + override fun setModelData(data: List) { + if (isTreeAvailable && isTreeMode) { + val allSelected = filterTree.checkBoxTreeSelectionModel.isRowSelected(0) + + val previousExpandedPaths = (0.. + if (filterTree.isExpanded(i)) { + (filterTree.getPathForRow(i).lastPathComponent as? LogEventNode)?.name + } else { + null + } + } + + if (allSelected) { + filterTree.model = DefaultTreeModel(RootNode(data as List)) + filterTree.selectAll() + } else { + val previousSelection = filterTree.checkBoxTreeSelectionModel.selectionPaths.map { + (it.lastPathComponent as LogEventNode).name + } + + filterTree.model = DefaultTreeModel(RootNode(data as List)) + + val newSelection = previousSelection.mapNotNull { + filterTree.pathFromLogger(it) + }.toTypedArray() + + filterTree.checkBoxTreeSelectionModel.selectionPaths = newSelection + } + + val newExpandedPaths = previousExpandedPaths.map { + filterTree.pathFromLogger(it) + }.distinct() + + newExpandedPaths.forEach(filterTree::expandPath) + } + filterList.model = FilterModel.fromRawData(data, filterList.comparator) { it.logger } + } + + override fun filter(item: T): Boolean = if (isTreeMode) { item.logger in selectedTreeNodes } else { item.logger in filterList.checkBoxListSelectedValues @@ -124,7 +161,7 @@ class LoggerNamePanel(private val rawData: List) : override fun isFilterApplied(): Boolean = if (isTreeMode) { !filterTree.checkBoxTreeSelectionModel.isRowSelected(0) } else { - filterList.checkBoxListSelectedValues.singleOrNull() != CheckBoxList.ALL_ENTRY + !filterList.checkBoxListSelectionModel.isAllSelected() } private val mainListComponent = ButtonPanel(sortButtons).apply { @@ -173,11 +210,11 @@ class LoggerNamePanel(private val rawData: List) : } } - override fun customizePopupMenu(menu: JPopupMenu, column: Column, event: LogEvent) { + override fun customizePopupMenu(menu: JPopupMenu, column: Column, event: T) { if (isTreeMode) { menu.add( Action("Focus in Tree") { - val treePath = filterTree.pathFromLogger(event.logger) + val treePath = filterTree.pathFromLogger(event.logger) ?: return@Action filterTree.apply { selectionModel.clearSelection() @@ -228,10 +265,8 @@ class LoggerNamePanel(private val rawData: List) : return } - val currentSelection = filterList.checkBoxListSelectionModel.selectedIndices.let { indices -> - indices.map { - filterList.model.getElementAt(it) as String - } + val currentSelection = filterList.checkBoxListSelectionModel.selectedIndices.map { + filterList.model.getElementAt(it) as String } val treePaths = currentSelection.map { filterTree.pathFromLogger(it) } @@ -258,21 +293,27 @@ class LoggerNamePanel(private val rawData: List) : private val expandIcon = FlatSVGIcon("icons/bx-expand-vertical.svg").asActionIcon() private val collapseIcon = FlatSVGIcon("icons/bx-collapse-vertical.svg").asActionIcon() - // It's a shame that there's no easier way to do this. - private fun LogTree.pathFromLogger(logger: String): TreePath { + /* + * return a TreePath given a fully qualified logger name. + * Returns null if the logger does not exist in the tree model. + */ + private fun LogTree.pathFromLogger(logger: String): TreePath? { val loggerParts = logger.split(".") val selectedPath = mutableListOf(model.root as AbstractTreeNode) var currentNode = selectedPath.first() for (part in loggerParts) { + var foundChild = false for (child in currentNode.children) { child as LogEventNode if (child.userObject.last() == part) { selectedPath.add(child) currentNode = child + foundChild = true break } } + if (!foundChild) return null } return TreePath(selectedPath.toTypedArray()) diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/MDCPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/MDCPanel.kt index eb96f9ab..4240b158 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/MDCPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/MDCPanel.kt @@ -9,6 +9,7 @@ import io.github.inductiveautomation.kindling.log.MDCTableModel.MDCColumns import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.Column import io.github.inductiveautomation.kindling.utils.ColumnList +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.ReifiedJXTable import io.github.inductiveautomation.kindling.utils.asActionIcon @@ -18,6 +19,7 @@ import io.github.inductiveautomation.kindling.utils.getAll import net.miginfocom.swing.MigLayout import org.jdesktop.swingx.renderer.CheckBoxProvider import org.jdesktop.swingx.renderer.DefaultTableRenderer +import java.awt.EventQueue import java.awt.event.KeyEvent import java.awt.event.KeyListener import java.util.Vector @@ -29,24 +31,38 @@ import javax.swing.JMenuItem import javax.swing.JPanel import javax.swing.JPopupMenu import javax.swing.table.AbstractTableModel +import kotlin.properties.Delegates -internal class MDCPanel(events: List) : FilterPanel() { +internal class MDCPanel( + events: List, +) : FilterPanel(), FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-key.svg") - private val allMDCs = events.flatMap(SystemLogEvent::mdc) + private var allMDCs by Delegates.observable(events.flatMap(SystemLogEvent::mdc)) { _, _, new -> + countByKey = new + .groupingBy(MDC::key) + .eachCount() + .entries + .sortedByDescending(Map.Entry::value) + .associate(Map.Entry::toPair) - private val countByKey = allMDCs + countByKeyAndValue = new.groupingBy(MDC::toPair).eachCount() + + mdcValuesPerKey = new.groupBy(MDC::key).mapValues { it.value.distinct() } + } + + private var countByKey: Map = allMDCs .groupingBy(MDC::key) .eachCount() .entries .sortedByDescending(Map.Entry::value) .associate(Map.Entry::toPair) - private val countByKeyAndValue = allMDCs + private var countByKeyAndValue: Map, Int> = allMDCs .groupingBy(MDC::toPair) .eachCount() - private val mdcValuesPerKey = allMDCs + private var mdcValuesPerKey: Map> = allMDCs .groupBy(MDC::key) .mapValues { it.value.distinct() } @@ -223,18 +239,45 @@ internal class MDCPanel(events: List) : FilterPanel() } } + override fun setModelData(data: List) { + allMDCs = data.flatMap(SystemLogEvent::mdc) + + // Key Combobox + val selectedMdcKey = keyCombo.selectedItem?.let { + if (it !in countByKey.keys) null else it as String + } + keyCombo.model = DefaultComboBoxModel(countByKey.keys.sortedWith(AlphanumComparator()).toTypedArray()) + keyCombo.setSelectedItem(selectedMdcKey) + + // Value ComboBoc. Key ComboBox sets the model in an action listener. + EventQueue.invokeLater { + val selectedMdcValue = valueCombo.selectedItem?.let { + if (it !in countByKey.keys) null else it as String + } + valueCombo.setSelectedItem(selectedMdcValue) + } + + // Remove MDCs which are no longer in the data + val toRemove = tableModel.data.withIndex().filter { (_, row) -> + val mdcsWithKey = mdcValuesPerKey[row.key] ?: return@filter true + row.value !in mdcsWithKey.map { it.value } + }.map { (index, _) -> index } + + toRemove.forEach(tableModel::removeAt) + } + override fun isFilterApplied(): Boolean = tableModel.data.isNotEmpty() override val tabName: String = "MDC" - override fun filter(item: LogEvent): Boolean = tableModel.filter(item) + override fun filter(item: SystemLogEvent): Boolean = tableModel.filter(item) override fun customizePopupMenu( menu: JPopupMenu, - column: Column, - event: LogEvent, + column: Column, + event: SystemLogEvent, ) { - if (column == SystemLogColumns.Message && (event as SystemLogEvent).mdc.isNotEmpty()) { + if (column == SystemLogColumns.Message && event.mdc.isNotEmpty()) { for ((key, values) in event.mdc.groupBy { it.key }) { menu.add( JMenu("MDC: '$key'").apply { diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/Model.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/Model.kt index 69326d62..1d38f142 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/Model.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/Model.kt @@ -1,8 +1,13 @@ package io.github.inductiveautomation.kindling.log +import io.github.inductiveautomation.kindling.utils.FileFilterableCollection import io.github.inductiveautomation.kindling.utils.StackTrace import java.time.Instant +data class LogFile( + override val items: List, +) : FileFilterableCollection + sealed interface LogEvent { var marked: Boolean val timestamp: Instant @@ -43,7 +48,7 @@ data class SystemLogEvent( override var marked: Boolean = false, ) : LogEvent -@Suppress("ktlint:trailing-comma-on-declaration-site") +@Suppress("ktlint:standard:trailing-comma-on-declaration-site") enum class Level { TRACE, DEBUG, diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/SystemLogPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/SystemLogPanel.kt new file mode 100644 index 00000000..b4e1a2a1 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/SystemLogPanel.kt @@ -0,0 +1,170 @@ +package io.github.inductiveautomation.kindling.log + +import io.github.inductiveautomation.kindling.idb.IdbView +import io.github.inductiveautomation.kindling.utils.FileFilterSidebar +import io.github.inductiveautomation.kindling.utils.SQLiteConnection +import io.github.inductiveautomation.kindling.utils.TabStrip +import io.github.inductiveautomation.kindling.utils.executeQuery +import io.github.inductiveautomation.kindling.utils.get +import io.github.inductiveautomation.kindling.utils.toList +import io.github.inductiveautomation.kindling.utils.toMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import java.awt.Container +import java.nio.file.Path +import java.sql.Connection +import java.time.Instant +import javax.swing.SwingUtilities + +class SystemLogPanel( + paths: List, + fileData: List>, +) : LogPanel(fileData.flatMap { it.items }, SystemLogColumns) { + + override val sidebar = FileFilterSidebar( + listOf( + LoggerNamePanel(rawData), + LevelPanel(rawData), + MDCPanel(rawData), + ThreadPanel(rawData), + TimePanel(rawData), + ), + fileData = paths.zip(fileData).toMap(), + ) + + init { + filters.add { event -> + val text = header.search.text + if (text.isNullOrEmpty()) { + true + } else { + text in event.message || + event.logger.contains(text, ignoreCase = true) || + event.thread.contains(text, ignoreCase = true) || + event.stacktrace.any { stacktrace -> + stacktrace.contains(text, ignoreCase = true) + } + } + } + + addSidebar(sidebar) + + sidebar.forEach { filterPanel -> + filterPanel.addFilterChangeListener { + if (!sidebar.filterModelsAreAdjusting) updateData() + } + } + + if (paths.size > 1) { + sidebar.addFileFilterChangeListener { + selectedData = sidebar.selectedFiles.flatMap { it.items } + + // Since this toolPanel is not a direct child of MainPanel's tabstrip, this is what must be done. + + val mainTabbedPane = SwingUtilities.getAncestorNamed("MainTabStrip", this) as? TabStrip + val parentToolPanel: Container? = SwingUtilities.getAncestorOfClass(IdbView::class.java, this) + + if (mainTabbedPane != null && parentToolPanel != null) { + val index = mainTabbedPane.indexOfComponent(parentToolPanel) + + mainTabbedPane.setTitleAt(index, "System Logs [${sidebar.allData.size}]") + mainTabbedPane.setToolTipTextAt(index, sidebar.allData.keys.joinToString("\n")) + } + } + + sidebar.registerHighlighters(table) + } + + sidebar.configureFileDrop { files -> + val newFileData = runBlocking { + files.map { path -> + async(Dispatchers.IO) { + val connection = SQLiteConnection(path) + val logFile = LogFile( + connection.parseLogs().also { connection.close() }, + ) + path to logFile + } + }.awaitAll() + } + + rawData.addAll( + newFileData.flatMap { it.second.items }, + ) + + newFileData.toMap() + } + } + + companion object { + fun Connection.parseLogs(): List { + val stackTraces: Map> = executeQuery( + """ + SELECT + event_id, + trace_line + FROM + logging_event_exception + ORDER BY + event_id, + i + """.trimIndent(), + ) + .toMap> { rs -> + val key: Long = rs["event_id"] + val valueList = getOrPut(key, ::mutableListOf) + valueList.add(rs["trace_line"]) + } + + val mdcKeys: Map> = executeQuery( + """ + SELECT + event_id, + mapped_key, + mapped_value + FROM + logging_event_property + ORDER BY + event_id + """.trimIndent(), + ).toMap> { rs -> + val key: Long = rs["event_id"] + val valueList = getOrPut(key, ::mutableListOf) + valueList += + MDC( + rs["mapped_key"], + rs["mapped_value"], + ) + } + + return executeQuery( + """ + SELECT + event_id, + timestmp, + formatted_message, + logger_name, + level_string, + thread_name + FROM + logging_event + ORDER BY + timestmp + """.trimIndent(), + ).toList { rs -> + val eventId: Long = rs["event_id"] + SystemLogEvent( + timestamp = Instant.ofEpochMilli(rs["timestmp"]), + message = rs["formatted_message"], + logger = rs["logger_name"], + thread = rs["thread_name"], + level = enumValueOf(rs["level_string"]), + mdc = mdcKeys[eventId].orEmpty(), + stacktrace = stackTraces[eventId].orEmpty(), + ) + } + } + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/TableModel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/TableModel.kt index 27d1960f..77e14341 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/TableModel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/TableModel.kt @@ -7,22 +7,18 @@ import io.github.inductiveautomation.kindling.log.LogViewer.TimeStampFormatter import io.github.inductiveautomation.kindling.utils.Column import io.github.inductiveautomation.kindling.utils.ColumnList import io.github.inductiveautomation.kindling.utils.ReifiedLabelProvider +import io.github.inductiveautomation.kindling.utils.ReifiedListTableModel import io.github.inductiveautomation.kindling.utils.StringProvider import io.github.inductiveautomation.kindling.utils.asActionIcon import org.jdesktop.swingx.renderer.DefaultTableRenderer import org.jdesktop.swingx.renderer.StringValues import java.time.Instant -import javax.swing.table.AbstractTableModel class LogsModel( - val data: List, - val columns: LogColumnList, -) : AbstractTableModel() { - override fun getColumnName(column: Int): String = columns[column].header - override fun getRowCount(): Int = data.size - override fun getColumnCount(): Int = columns.size - override fun getValueAt(row: Int, column: Int): Any? = get(row, columns[column]) - override fun getColumnClass(column: Int): Class<*> = columns[column].clazz + data: List, + override val columns: LogColumnList, +) : ReifiedListTableModel(data, columns) { + override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean { return columnIndex == markIndex } @@ -34,13 +30,6 @@ class LogsModel( }, ] - operator fun get(row: Int): T = data[row] - operator fun get(row: Int, column: Column): R? { - return data.getOrNull(row)?.let { event -> - column.getValue(event) - } - } - override fun setValueAt(aValue: Any?, rowIndex: Int, columnIndex: Int) { require(isCellEditable(rowIndex, columnIndex)) data[rowIndex].marked = aValue as Boolean diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/ThreadPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/ThreadPanel.kt index ce844da8..56036bbd 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/ThreadPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/ThreadPanel.kt @@ -3,29 +3,36 @@ package io.github.inductiveautomation.kindling.log import com.formdev.flatlaf.extras.FlatSVGIcon import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.Column +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FilterListPanel import io.github.inductiveautomation.kindling.utils.FilterModel import javax.swing.JPopupMenu -internal class ThreadPanel(events: List) : FilterListPanel("Threads") { +internal class ThreadPanel( + events: List, +) : FilterListPanel("Threads"), FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-chip.svg") init { filterList.apply { - setModel(FilterModel(events.groupingBy { (it as SystemLogEvent).thread }.eachCount())) + setModel(FilterModel.fromRawData(events, filterList.comparator) { it.thread }) selectAll() } } - override fun filter(item: LogEvent) = (item as SystemLogEvent).thread in filterList.checkBoxListSelectedValues + override fun setModelData(data: List) { + filterList.model = FilterModel.fromRawData(data, filterList.comparator) { it.thread } + } + + override fun filter(item: SystemLogEvent) = item.thread in filterList.checkBoxListSelectedValues override fun customizePopupMenu( menu: JPopupMenu, - column: Column, - event: LogEvent, + column: Column, + event: SystemLogEvent, ) { if (column == SystemLogColumns.Thread) { - val threadIndex = filterList.model.indexOf((event as SystemLogEvent).thread) + val threadIndex = filterList.model.indexOf(event.thread) menu.add( Action("Show only ${event.thread} events") { filterList.checkBoxListSelectedIndex = threadIndex diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/TimePanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/TimePanel.kt index 9ffd3a7a..646d5a87 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/TimePanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/TimePanel.kt @@ -10,6 +10,7 @@ import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.Column import io.github.inductiveautomation.kindling.utils.ColumnList import io.github.inductiveautomation.kindling.utils.EmptyBorder +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.ReifiedJXTable import io.github.inductiveautomation.kindling.utils.ReifiedListTableModel @@ -53,19 +54,19 @@ import javax.swing.UIManager import javax.swing.border.LineBorder import kotlin.math.absoluteValue -internal class TimePanel( - data: List, -) : FilterPanel() { +internal class TimePanel( + data: List, +) : FilterPanel(), FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-time-five.svg") - private val lowerBound: Instant = data.first().timestamp - private val upperBound: Instant = data.last().timestamp + private var lowerBound: Instant = data.first().timestamp + private var upperBound: Instant = data.last().timestamp private var coveredRange: ClosedRange = lowerBound..upperBound - private val initialRange = coveredRange + private var totalCurrentRange = coveredRange - private val startSelector = DateTimeSelector(lowerBound, initialRange) - private val endSelector = DateTimeSelector(upperBound, initialRange) + private val startSelector = DateTimeSelector(lowerBound, totalCurrentRange) + private val endSelector = DateTimeSelector(upperBound, totalCurrentRange) private val denseMinutesTable = ReifiedJXTable( @@ -195,8 +196,8 @@ internal class TimePanel( add(FlatScrollPane(denseMinutesTable), "pushx, growx") } - private val containingLogPanel: LogPanel? - get() = component.getAncestorOfClass() + private val containingLogPanel: LogPanel<*>? + get() = component.getAncestorOfClass>() init { startSelector.addPropertyChangeListener("time") { @@ -207,7 +208,7 @@ internal class TimePanel( } } - override fun isFilterApplied(): Boolean = coveredRange != initialRange + override fun isFilterApplied(): Boolean = coveredRange != totalCurrentRange private fun updateCoveredRange() { coveredRange = startSelector.time..endSelector.time @@ -215,12 +216,51 @@ internal class TimePanel( listeners.getAll().forEach(FilterChangeListener::filterChanged) } - override fun filter(item: LogEvent): Boolean = item.timestamp in coveredRange + override fun filter(item: T): Boolean = item.timestamp in coveredRange + + override fun setModelData(data: List) { + val isFilterApplied = isFilterApplied() + lowerBound = data.minOf { it.timestamp } + upperBound = data.maxOf { it.timestamp } + totalCurrentRange = lowerBound..upperBound + + denseMinutesTable.model = ReifiedListTableModel( + data.groupingBy { it.timestamp.truncatedTo(ChronoUnit.MINUTES) } + .eachCount() + .entries + .filter { it.value > 60 } + .map { entry -> + DenseTime(entry.key, entry.value) + }, + DensityColumns, + ) + + startSelector.range = totalCurrentRange + startSelector.defaultValue = lowerBound + + endSelector.range = totalCurrentRange + endSelector.defaultValue = upperBound + + if (!isFilterApplied) { + reset() + return + } + + if (lowerBound > startSelector.time) { + startSelector.time = lowerBound + } + + if (upperBound < startSelector.time) { + startSelector.time = upperBound + } + + updateCoveredRange() + } override fun customizePopupMenu( menu: JPopupMenu, - column: Column, - event: LogEvent, + column: Column, + event: T, ) { if (column == WrapperLogColumns.Timestamp || column == SystemLogColumns.Timestamp) { menu.add( @@ -254,12 +294,22 @@ private var JXDatePicker.localDate: LocalDate? } class DateTimeSelector( - private val initialValue: Instant, - private val range: ClosedRange, + var defaultValue: Instant, + initialRange: ClosedRange, ) : JPanel(MigLayout("ins 0")) { - private val initialZonedTime = initialValue.atZone(LogViewer.SelectedTimeZone.currentValue) + var range: ClosedRange = initialRange + set(value) { + field = value + datePicker.monthView.apply { + lowerBound = Date.from(value.start) + upperBound = Date.from(value.endInclusive) + } + } + + private val initialZonedTime: ZonedDateTime + get() = defaultValue.atZone(LogViewer.SelectedTimeZone.currentValue) - private var datePicker = + private val datePicker = JXDatePicker().apply { localDate = initialZonedTime.toLocalDate() editor.horizontalAlignment = SwingConstants.CENTER @@ -281,25 +331,24 @@ class DateTimeSelector( } } - private val timeSelector = - TimeSelector().apply { - localTime = initialZonedTime.toLocalTime() - addPropertyChangeListener("localTime") { - firePropertyChange("time", null, time) - } + private val timeSelector = TimeSelector().apply { + localTime = initialZonedTime.toLocalTime() + addPropertyChangeListener("localTime") { + firePropertyChange("time", null, time) } + } var time: Instant get() { val localDate = datePicker.localDate return if (localDate == null) { - initialValue + defaultValue } else { ZonedDateTime.of( localDate, timeSelector.localTime, LogViewer.SelectedTimeZone.currentValue, - ).toInstant() ?: initialValue + ).toInstant() ?: defaultValue } } set(value) { @@ -455,7 +504,7 @@ private data class DenseTime( ) private object DensityColumns : ColumnList() { - @Suppress("PropertyName") + @Suppress("PropertyName", "RedundantSuppression") private lateinit var _formatter: DateTimeFormatter private val minuteFormatter: DateTimeFormatter diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogView.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogPanel.kt similarity index 65% rename from src/main/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogView.kt rename to src/main/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogPanel.kt index f6be66e0..5c66ddf7 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogPanel.kt @@ -8,52 +8,101 @@ import io.github.inductiveautomation.kindling.core.MultiTool import io.github.inductiveautomation.kindling.core.Preference.Companion.preference import io.github.inductiveautomation.kindling.core.PreferenceCategory import io.github.inductiveautomation.kindling.core.ToolPanel -import io.github.inductiveautomation.kindling.utils.Action import io.github.inductiveautomation.kindling.utils.FileFilter +import io.github.inductiveautomation.kindling.utils.FileFilterSidebar +import io.github.inductiveautomation.kindling.utils.TabStrip import io.github.inductiveautomation.kindling.utils.ZoneIdSerializer import io.github.inductiveautomation.kindling.utils.getValue -import java.awt.Desktop -import java.io.File +import io.github.inductiveautomation.kindling.utils.transferTo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import java.nio.file.Files import java.nio.file.Path import java.time.Instant -import java.time.LocalTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.zone.ZoneRulesProvider import java.util.Vector -import javax.swing.Icon import javax.swing.JComboBox -import javax.swing.JPopupMenu +import javax.swing.SwingUtilities +import kotlin.io.path.absolutePathString import kotlin.io.path.name +import kotlin.io.path.outputStream import kotlin.io.path.useLines -class WrapperLogView( - events: List, - tabName: String, - private val fromFile: Boolean, -) : ToolPanel() { - private val logPanel = LogPanel(events) +class WrapperLogPanel( + paths: List, + fileData: List>, +) : LogPanel(fileData.flatMap { it.items }, WrapperLogColumns) { + + override val sidebar = FileFilterSidebar( + listOf( + LoggerNamePanel(rawData), + LevelPanel(rawData), + TimePanel(rawData), + ), + fileData = paths.zip(fileData).toMap(), + ) init { - name = tabName - toolTipText = tabName + name = "Wrapper Logs [${fileData.size}]" + toolTipText = paths.joinToString("\n") { it.absolutePathString() } + + filters.add { event -> + val text = header.search.text + if (text.isNullOrEmpty()) { + true + } else { + text in event.message || + event.logger.contains(text, ignoreCase = true) || + event.stacktrace.any { stacktrace -> stacktrace.contains(text, ignoreCase = true) } + } + } - add(logPanel, "push, grow") - } + addSidebar(sidebar) - override val icon: Icon = LogViewer.icon + sidebar.forEach { filterPanel -> + filterPanel.addFilterChangeListener { + if (!sidebar.filterModelsAreAdjusting) updateData() + } + } - override fun customizePopupMenu(menu: JPopupMenu) { - menu.add( - exportMenu { logPanel.table.model }, - ) - if (fromFile) { - menu.addSeparator() - menu.add( - Action(name = "Open in External Editor") { - Desktop.getDesktop().open(File(tabName)) - }, + if (paths.size > 1) { + sidebar.addFileFilterChangeListener { + selectedData = sidebar.selectedFiles.flatMap { it.items } + + val mainTabbedPane = SwingUtilities.getAncestorNamed("MainTabStrip", this) as? TabStrip + + if (mainTabbedPane != null) { + val index = mainTabbedPane.indexOfComponent(this) + + mainTabbedPane.setTitleAt(index, "System Logs [${sidebar.allData.size}]") + mainTabbedPane.setToolTipTextAt(index, sidebar.allData.keys.joinToString("\n")) + } + } + + sidebar.registerHighlighters(table) + } + + sidebar.configureFileDrop { files -> + val newFileData = runBlocking { + files.map { path -> + async(Dispatchers.IO) { + val logFile = path.useLines { + LogFile(parseLogs(it)) + } + path to logFile + } + }.awaitAll() + } + + rawData.addAll( + newFileData.flatMap { it.second.items }, ) + + newFileData.toMap() } } @@ -144,21 +193,28 @@ data object LogViewer : MultiTool, ClipboardTool, PreferenceCategory { require(paths.isNotEmpty()) { "Must provide at least one path" } // flip the paths, so the .5, .4, .3, .2, .1 - this hopefully helps with the per-event sort below val reverseOrder = paths.sortedWith(compareBy(AlphanumComparator(), Path::name).reversed()) - val events = reverseOrder.flatMap { path -> - path.useLines(DefaultEncoding.currentValue) { lines -> WrapperLogView.parseLogs(lines) } + val fileData = reverseOrder.map { path -> + path.useLines(DefaultEncoding.currentValue) { lines -> + LogFile(WrapperLogPanel.parseLogs(lines)) + } } - return WrapperLogView( - events = events.sortedBy { it.timestamp }, - tabName = paths.first().name, - fromFile = true, + return WrapperLogPanel( + reverseOrder, + fileData, ) } - override fun open(data: String): ToolPanel = WrapperLogView( - events = WrapperLogView.parseLogs(data.lineSequence()), - tabName = "Paste at ${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))}", - fromFile = false, - ) + override fun open(data: String): ToolPanel { + val tempFile = Files.createTempFile("paste", "kindl") + data.byteInputStream() transferTo tempFile.outputStream() + + val fileData = LogFile( + tempFile.useLines(DefaultEncoding.currentValue) { lines -> + WrapperLogPanel.parseLogs(lines) + }, + ) + return WrapperLogPanel(listOf(tempFile), listOf(fileData)) + } val SelectedTimeZone = preference( name = "Timezone", 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..c28eed1f 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/MultiThreadView.kt @@ -21,11 +21,11 @@ import io.github.inductiveautomation.kindling.thread.model.ThreadModel import io.github.inductiveautomation.kindling.thread.model.ThreadModel.MultiThreadColumns import io.github.inductiveautomation.kindling.thread.model.ThreadModel.SingleThreadColumns import io.github.inductiveautomation.kindling.utils.Action +import io.github.inductiveautomation.kindling.utils.ColorHighlighter import io.github.inductiveautomation.kindling.utils.Column import io.github.inductiveautomation.kindling.utils.EDT_SCOPE import io.github.inductiveautomation.kindling.utils.FileFilter -import io.github.inductiveautomation.kindling.utils.FilterModel -import io.github.inductiveautomation.kindling.utils.FilterSidebar +import io.github.inductiveautomation.kindling.utils.FileFilterSidebar import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.HorizontalSplitPane import io.github.inductiveautomation.kindling.utils.ReifiedJXTable @@ -40,7 +40,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch 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 @@ -70,6 +69,15 @@ class MultiThreadView( private val statePanel = StatePanel() private val searchField = JXSearchField("Search") + private val sidebar = FileFilterSidebar( + listOf( + statePanel, + systemPanel, + poolPanel, + ), + fileData = paths.zip(threadDumps).toMap(), + ) + private var visibleThreadDumps: List = emptyList() set(value) { field = value @@ -80,15 +88,7 @@ class MultiThreadView( private var currentLifespanList: List = emptyList() set(value) { field = value - val allThreads = value.flatten().filterNotNull() - if (allThreads.isNotEmpty()) { - statePanel.stateList.setModel(FilterModel(allThreads.groupingBy { it.state.name }.eachCount())) - systemPanel.filterList.setModel(FilterModel(allThreads.groupingBy(Thread::system).eachCount())) - poolPanel.filterList.setModel(FilterModel(allThreads.groupingBy(Thread::pool).eachCount())) - } - if (initialized) { - updateData() - } + if (initialized) updateData() } private val threadCountLabel = object : JLabel() { @@ -124,14 +124,13 @@ class MultiThreadView( addHighlighter( ColorHighlighter( - { _, adapter -> - threadDumps.any { threadDump -> - model[adapter.row, model.columns.id] in threadDump.deadlockIds - } - }, UIManager.getColor("Actions.Red"), null, - ), + ) { _, adapter -> + threadDumps.any { threadDump -> + model[adapter.row, model.columns.id] in threadDump.deadlockIds + } + }, ) fun toggleMarkAllWithSameValue(property: Column) { @@ -220,20 +219,8 @@ class MultiThreadView( } } - private val sidebar = FilterSidebar( - statePanel, - systemPanel, - poolPanel, - ) - private var comparison = ThreadComparisonPane(threadDumps.size, threadDumps[0].version) - private val threadDumpCheckboxList = ThreadDumpCheckboxList(paths).apply { - isVisible = !mainTable.model.isSingleContext - } - - private var listModelIsAdjusting = false - private val exportMenu = run { val firstThreadDump = threadDumps.first() val fileName = "threaddump_${firstThreadDump.version}_${firstThreadDump.hashCode()}" @@ -246,7 +233,7 @@ class MultiThreadView( } private val filters = buildList> { - addAll(sidebar.filterPanels) + addAll(sidebar) add { thread -> thread != null } @@ -328,9 +315,9 @@ class MultiThreadView( statePanel.stateList.selectAll() systemPanel.filterList.selectAll() - sidebar.filterPanels.forEach { panel -> + sidebar.forEach { panel -> panel.addFilterChangeListener { - if (!listModelIsAdjusting) updateData() + if (!sidebar.filterModelsAreAdjusting) updateData() } } @@ -338,21 +325,10 @@ class MultiThreadView( updateData() } - threadDumpCheckboxList.checkBoxListSelectionModel.apply { - addListSelectionListener { event -> - if (!event.valueIsAdjusting) { - listModelIsAdjusting = true - - val selectedThreadDumps = List(threadDumps.size) { i -> - if (isSelectedIndex(i + 1)) { - threadDumps[i] - } else { - null - } - } - visibleThreadDumps = selectedThreadDumps - listModelIsAdjusting = false - } + sidebar.addFileFilterChangeListener { + val selectedFiles = sidebar.selectedFiles + visibleThreadDumps = threadDumps.map { + if (it in selectedFiles) it else null } } @@ -382,7 +358,6 @@ class MultiThreadView( add(JLabel("Version: ${threadDumps.first().version}")) add(threadCountLabel) - add(threadDumpCheckboxList, "gapleft 20px, pushx, growx, shpx 200") add(exportButton, "gapright 8") add(searchField, "wmin 300, wrap") add( diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/PoolPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/PoolPanel.kt index c24ffb47..6d8e8603 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/PoolPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/PoolPanel.kt @@ -2,13 +2,21 @@ package io.github.inductiveautomation.kindling.thread import com.formdev.flatlaf.extras.FlatSVGIcon import io.github.inductiveautomation.kindling.thread.model.Thread +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FilterListPanel +import io.github.inductiveautomation.kindling.utils.FilterModel -class PoolPanel : FilterListPanel( - tabName = "Pool", - toStringFn = { it?.toString() ?: "(No Pool)" }, -) { +class PoolPanel : + FilterListPanel( + tabName = "Pool", + toStringFn = { it?.toString() ?: "(No Pool)" }, + ), + FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-chip.svg") + override fun setModelData(data: List) { + filterList.model = FilterModel.fromRawData(data.filterNotNull(), filterList.comparator) { it.pool } + } + override fun filter(item: Thread?) = item?.pool in filterList.checkBoxListSelectedValues } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/StatePanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/StatePanel.kt index ebafe720..5974caa6 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/StatePanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/StatePanel.kt @@ -5,12 +5,14 @@ import io.github.inductiveautomation.kindling.core.FilterChangeListener import io.github.inductiveautomation.kindling.core.FilterPanel import io.github.inductiveautomation.kindling.thread.model.Thread import io.github.inductiveautomation.kindling.utils.Column +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FilterList +import io.github.inductiveautomation.kindling.utils.FilterModel import io.github.inductiveautomation.kindling.utils.FlatScrollPane import io.github.inductiveautomation.kindling.utils.getAll import javax.swing.JPopupMenu -class StatePanel : FilterPanel() { +class StatePanel : FilterPanel(), FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-check-circle.svg") val stateList = FilterList() @@ -28,6 +30,10 @@ class StatePanel : FilterPanel() { } } + override fun setModelData(data: List) { + stateList.model = FilterModel.fromRawData(data.filterNotNull(), stateList.comparator) { it.state.name } + } + override fun isFilterApplied(): Boolean = stateList.checkBoxListSelectedValues.size != stateList.model.size - 1 override fun reset() = stateList.selectAll() diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/SystemPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/SystemPanel.kt index 521eda1f..3897fdb5 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/SystemPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/SystemPanel.kt @@ -2,13 +2,21 @@ package io.github.inductiveautomation.kindling.thread import com.formdev.flatlaf.extras.FlatSVGIcon import io.github.inductiveautomation.kindling.thread.model.Thread +import io.github.inductiveautomation.kindling.utils.FileFilterResponsive import io.github.inductiveautomation.kindling.utils.FilterListPanel +import io.github.inductiveautomation.kindling.utils.FilterModel -class SystemPanel : FilterListPanel( - tabName = "System", - toStringFn = { it?.toString() ?: "Unassigned" }, -) { +class SystemPanel : + FilterListPanel( + tabName = "System", + toStringFn = { it?.toString() ?: "Unassigned" }, + ), + FileFilterResponsive { override val icon = FlatSVGIcon("icons/bx-hdd.svg") + override fun setModelData(data: List) { + filterList.model = FilterModel.fromRawData(data.filterNotNull(), filterList.comparator) { it.system } + } + override fun filter(item: Thread?): Boolean = item?.system in filterList.checkBoxListSelectedValues } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/model/ThreadDump.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/model/ThreadDump.kt index ef098f39..3c48cdef 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/thread/model/ThreadDump.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/thread/model/ThreadDump.kt @@ -2,10 +2,12 @@ package io.github.inductiveautomation.kindling.thread.model import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.DefaultEncoding import io.github.inductiveautomation.kindling.core.ToolOpeningException +import io.github.inductiveautomation.kindling.utils.FileFilterableCollection import io.github.inductiveautomation.kindling.utils.getValue import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import java.io.InputStream import java.lang.Thread.State as ThreadState @@ -17,7 +19,10 @@ data class ThreadDump internal constructor( val threads: List, @SerialName("deadlocks") val deadlockIds: List = emptyList(), -) { +) : FileFilterableCollection { + @Transient + override val items = threads + companion object { private val JSON = Json { ignoreUnknownKeys = true diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FileFilterSidebar.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FileFilterSidebar.kt new file mode 100644 index 00000000..53724f35 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FileFilterSidebar.kt @@ -0,0 +1,579 @@ +package io.github.inductiveautomation.kindling.utils + +import com.formdev.flatlaf.extras.FlatSVGIcon +import com.formdev.flatlaf.util.SystemInfo +import io.github.inductiveautomation.kindling.core.CustomIconView +import io.github.inductiveautomation.kindling.core.FilterPanel +import io.github.inductiveautomation.kindling.core.Kindling +import io.github.inductiveautomation.kindling.core.Kindling.Preferences.General.HomeLocation +import io.github.inductiveautomation.kindling.core.Tool +import io.github.inductiveautomation.kindling.internal.FileTransferHandler +import net.miginfocom.swing.MigLayout +import org.jdesktop.swingx.decorator.ColorHighlighter +import org.jdesktop.swingx.renderer.DefaultTableRenderer +import java.awt.Color +import java.awt.Cursor +import java.awt.Desktop +import java.awt.EventQueue +import java.awt.event.MouseEvent +import java.awt.event.MouseMotionAdapter +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.EventListener +import java.util.IdentityHashMap +import javax.swing.JButton +import javax.swing.JCheckBox +import javax.swing.JComponent +import javax.swing.JFileChooser +import javax.swing.JLabel +import javax.swing.JOptionPane +import javax.swing.JPanel +import javax.swing.JPopupMenu +import javax.swing.UIManager +import javax.swing.event.TableModelEvent +import javax.swing.event.TableModelListener +import kotlin.io.path.absolutePathString +import kotlin.io.path.div +import kotlin.io.path.name +import kotlin.time.Duration.Companion.milliseconds + +/** + * A Filter Sidebar which automatically manages its filters by setting the models appropriately. + * The FilterPanel must implement setModelData() in order for it to be responsive to file changes. + * - Files must correspond to an instance of the `FileFilterableCollection` interface. + * - `FilterPanel`s added to this sidebar must implement the `FileFilterResponsive` interface + */ +class FileFilterSidebar private constructor( + initialPanels: List?>, + initialFileData: Map>, +) : FilterSidebar(initialPanels.filterNotNull()), TableModelListener { + private val filePanel = FileFilterPanel(initialFileData) + + private lateinit var fileChooser: JFileChooser + + private val highlighters = mutableMapOf, ColorHighlighter>() + + var filterModelsAreAdjusting = false + private set + + val selectedFiles by filePanel::selectedFiles + + val allData: Map> + get() = filePanel.table.model.data.associate { it.path to it.filterableCollection } + + init { + val initialData = initialFileData.values.flatMap { it.items } + for (panel in this) { + if (panel is FileFilterResponsive<*>) { + @Suppress("unchecked_cast") + panel as FileFilterResponsive + panel.setModelData(initialData) + } + panel.reset() + } + + if (initialFileData.size > 1) { + addTab( + null, + filePanel.icon, + filePanel.component, + filePanel.formattedTabName, + ) + + filePanel.table.model.addTableModelListener(this) + } + + attachPopupMenu { event -> + val tabIndex = indexAtLocation(event.x, event.y) + if (getComponentAt(tabIndex) !== filePanel.component) return@attachPopupMenu null + + JPopupMenu().apply { + add( + Action("Reset") { + filePanel.reset() + }, + ) + } + } + } + + private var selectedFileIndices = filePanel.table.model.data.mapIndexedNotNull { index, item -> + if (item.show) index else null + } + + override fun tableChanged(e: TableModelEvent?) = with(filePanel.table.model) { + if (e?.column == columns[columns.Show] || e?.type == TableModelEvent.INSERT) { + update.invoke() + } + } + + private val update = debounce(400.milliseconds, EDT_SCOPE) { + val newSelectedIndices = filePanel.table.model.data.mapIndexedNotNull { index, item -> + if (item.show) index else null + } + + if (newSelectedIndices == selectedFileIndices) { + return@debounce + } else { + selectedFileIndices = newSelectedIndices + } + + filterModelsAreAdjusting = true + + val selectedData = selectedFiles.flatMap { f -> f.items } + if (selectedData.isNotEmpty()) { + for (panel in this) { + @Suppress("unchecked_cast") + (panel as? FileFilterResponsive)?.setModelData(selectedData) + } + } + filterModelsAreAdjusting = false + + filePanel.updateTabState() + + // Fire the "external" listeners. + listenerList.getAll().forEach { e -> e.fileFilterChanged() } + } + + /** + * Provide a reference to the table which will "subscribe" to highlighter changes and additions. + * + * @param table The table which will be highlighting rows based on the current data + */ + fun registerHighlighters(table: ReifiedJXTable>) { + highlighters.clear() + + highlighters.putAll( + filePanel.table.model.data.associate { (_, file, color, _) -> + file to ColorHighlighter(color, null) { _, adapter -> + val itemAtRow = table.model[adapter.convertRowIndexToModel(adapter.row)] + val itemFile = filePanel.filesByFilterItems[itemAtRow] + itemFile === file + } + }, + ) + + if (filePanel.enableHighlightCheckbox.isSelected) { + filePanel.table.getColumnExt(filePanel.table.model.columns.Color).isVisible = true + highlighters.values.forEach(table::addHighlighter) + } + + filePanel.table.model.apply { + addTableModelListener { tableModelEvent -> + if (tableModelEvent.column == columns[columns.Color]) { + val rowData = data[tableModelEvent.firstRow] + + table.removeHighlighter(highlighters[rowData.filterableCollection]) + + val newHighlighter = ColorHighlighter(rowData.color, null) { _, adapter -> + val logEventAtRow = table.model[adapter.convertRowIndexToModel(adapter.row)] + val eventLogFile = filePanel.filesByFilterItems.getOrElse(logEventAtRow) { + error("No file for filter Item") + } + eventLogFile === rowData.filterableCollection + } + + highlighters[rowData.filterableCollection] = newHighlighter + table.addHighlighter(newHighlighter) + } + } + } + + filePanel.enableHighlightCheckbox.apply { + isVisible = true + + addActionListener { + val checkbox = it.source as JCheckBox + if (checkbox.isSelected) { + filePanel.table.getColumnExt(filePanel.table.model.columns.Color).isVisible = true + highlighters.values.forEach(table::addHighlighter) + } else { + filePanel.table.getColumnExt(filePanel.table.model.columns.Color).isVisible = false + highlighters.values.forEach(table::removeHighlighter) + } + } + } + } + + /** + * Configure file drop functionality for the sidebar. + * + * @param configure A Function which is responsible for mapping the dropped list of files + * to a map of paths to `FileFilterableCollection`. This gives the `ToolPanel` the opportunity + * to do what it needs with the data before passing it back to the sidebar for filter updating. + */ + fun configureFileDrop( + configure: (List) -> Map>, + ) { + filePanel.dropEnabled = true + + filePanel.fileDropButton.transferHandler = FileTransferHandler { files -> + try { + val paths = files.map { it.toPath() } + val addedEntries = configure(paths) // Might throw exception + + addFileEntries(addedEntries) + } catch (e: Exception) { + e.printStackTrace() + JOptionPane.showMessageDialog( + null, + "Unable to open file:\n${e.message}", + "Error", + JOptionPane.ERROR_MESSAGE, + ) + } + } + + filePanel.fileDropButton.addActionListener { + if (!::fileChooser.isInitialized) { + val firstFile = filePanel.table.model.data.firstOrNull()?.path + fileChooser = JFileChooser((firstFile ?: HomeLocation.currentValue).toFile()).apply { + isMultiSelectionEnabled = true + fileView = CustomIconView() + } + + if (firstFile != null) { + fileChooser.fileFilter = Tool[firstFile].filter + } + } + + try { + val newEntries = fileChooser.chooseFiles(null)?.map(File::toPath)?.let { + configure(it) + } + + if (!newEntries.isNullOrEmpty()) addFileEntries(newEntries) + } catch (e: Exception) { + JOptionPane.showMessageDialog( + null, + "Unable to open file:\n${e.message}", + "Error", + JOptionPane.ERROR_MESSAGE, + ) + } + } + } + + private fun addFileEntries(addedEntries: Map>) { + val startIndex = filePanel.table.model.rowCount + val endIndex = startIndex + addedEntries.size - 1 + + // Add new data to the table and to the item map + filePanel.table.model.data.addAll( + addedEntries.entries.map { (path, file) -> + FileFilterConfigItem( + path = path, + filterableCollection = file, + color = UIManager.getColor("Table.background"), + show = true, + ) + }, + ) + + filePanel.filesByFilterItems.putAll( + addedEntries.entries.flatMap { (_, c) -> c.items.map { it to c } }, + ) + + filePanel.table.model.fireTableRowsInserted(startIndex, endIndex) + } + + /** + * A special listener fired when entire files are filtered in and out or added. + */ + fun addFileFilterChangeListener(l: FileFilterChangeListener) { + listenerList.add(l) + } + + private inner class FileFilterPanel( + data: Map>, + ) : FilterPanel>() { + + // A map whose keys are unique by the system default implementations of hashcode() + // Used for O(n)-ish access to an item's parent collection + val filesByFilterItems: MutableMap> = run { + val mappedData = data.flatMap { (_, c) -> c.items.map { it to c } } + IdentityHashMap>(mappedData.size * 2).apply { + putAll(mappedData) + } + } + + val enableHighlightCheckbox = JCheckBox( + "Enable Highlight", + Kindling.Preferences.General.HighlightByDefault.currentValue, + ).apply { + horizontalTextPosition = JCheckBox.RIGHT + isVisible = false + } + + val table = ReifiedJXTable( + FileFilterTableModel( + data.entries.run { + val palette = if (Kindling.Preferences.UI.Theme.currentValue.isDark) DARK_COLORS else LIGHT_COLORS + + mapIndexed { index, (path, filterable) -> + FileFilterConfigItem( + path = path, + filterableCollection = filterable, + color = palette.getOrElse(index) { + if (Kindling.Preferences.UI.Theme.currentValue.isDark) Color.DARK_GRAY else Color.LIGHT_GRAY + }, + show = true, + ) + } + }.toMutableList(), + ), + ).apply { + dragEnabled = false + isColumnControlVisible = false + selectionModel = NoSelectionModel() + + // Hide Color column until highlighting is configured + getColumnExt(model.columns.Color).isVisible = false + + highlighters.forEach(::removeHighlighter) + + addMouseMotionListener( + object : MouseMotionAdapter() { + override fun mouseMoved(e: MouseEvent?) { + if (e == null) return + + val colViewIndex = columnAtPoint(e.point) + val rowIndex = rowAtPoint(e.point) + val colModelIndex = convertColumnIndexToModel(colViewIndex) + val col = model.columns.getOrNull(colModelIndex) + + cursor = when { + rowIndex < 0 -> Cursor.getDefaultCursor() + col == model.columns.Name -> Cursor.getPredefinedCursor(Cursor.TEXT_CURSOR) + col == model.columns.Color -> Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR) + else -> Cursor.getDefaultCursor() + } + } + }, + ) + + attachPopupMenu { e -> + val rowIndex = rowAtPoint(e.point) + + JPopupMenu().also { menu -> + // Open file Location + if (rowIndex >= 0) { + val desktop = Desktop.getDesktop() + + if (desktop.isSupported(Desktop.Action.BROWSE_FILE_DIR)) { + val fileBrowserName = when { + SystemInfo.isMacOS -> "Finder" + SystemInfo.isWindows -> "Explorer" + else -> "File Browser" + } + + menu.add( + Action("Open in $fileBrowserName") { + desktop.browseFileDirectory(model[rowIndex].path.toFile()) + }, + ) + } + + menu.add( + Action("Clear Highlight Color") { + model.data[rowIndex].color = null + model.fireTableCellUpdated(rowIndex, model.columns[model.columns.Color]) + }, + ) + } + } + } + } + + val fileDropButton = JButton("Drop files here").apply { + isVisible = false + putClientProperty("FlatLaf.styleClass", "h2.regular") + } + + var dropEnabled: Boolean + get() = fileDropButton.isVisible + set(value) { + fileDropButton.isVisible = value + } + + val selectedFiles: List> + get() = table.model.data.filter { it.show }.map { it.filterableCollection } + + override val tabName: String = "Files" + override val icon = FlatSVGIcon("icons/bx-file.svg") + + override val component: JComponent = JPanel(MigLayout("fill, ins 4, hidemode 3")).apply { + add(enableHighlightCheckbox, "north") + add(FlatScrollPane(table), "grow") + add(fileDropButton, "south, h 200!") + } + + override fun filter(item: FileFilterConfigItem) = item.show + + override fun isFilterApplied(): Boolean = table.model.data.any { !it.show } + + override fun reset() { + table.model.data.forEach { it.show = true } + table.model.fireTableColumnDataChanged(table.model.columns[table.model.columns.Show]) + } + + override fun customizePopupMenu( + menu: JPopupMenu, + column: Column, *>, + event: FileFilterConfigItem, + ) = Unit + } + + companion object { + /** + * Quiz: Figure out why this doesn't work with `vararg panels: S` + */ + operator fun invoke( + panels: List, + fileData: Map>, + ): FileFilterSidebar where S : FilterPanel, S : FileFilterResponsive { + return FileFilterSidebar(panels.toList(), fileData) + } + + // Can be adjusted for better colors. Default background for first 5 files opened. + // https://youtrack.jetbrains.com/issue/KT-2780 + private val LIGHT_COLORS: List = listOf( + Color(0x80DC8300.toInt(), true), + Color(0x80d9d200.toInt(), true), + Color(0x80f5b9b5.toInt(), true), + Color(0x800081ac.toInt(), true), + Color(0x80c1b0df.toInt(), true), + ) + + private val DARK_COLORS: List = listOf( + Color(0x80b115c5.toInt(), true), + Color(0x80ee1a5a.toInt(), true), + Color(0x80006b3b.toInt(), true), + Color(0x80aa1d0f.toInt(), true), + Color(0x80004d92.toInt(), true), + ) + } +} + +internal data class FileFilterConfigItem( + var path: Path, + val filterableCollection: FileFilterableCollection, + var color: Color?, + var show: Boolean, +) + +internal class FileFilterTableModel( + override val data: MutableList>, +) : ReifiedListTableModel>(data, FileFilterColumns()) { + override val columns: FileFilterColumns = super.columns as FileFilterColumns + + override fun isCellEditable(rowIndex: Int, columnIndex: Int) = true + + override fun setValueAt(aValue: Any?, rowIndex: Int, columnIndex: Int) { + when (columns[columnIndex]) { + columns.Name -> { + if (aValue !is String) return + + val oldPath = data[rowIndex].path + if (aValue == oldPath.name) return + + val newPath = oldPath.parent / aValue + + if ( + JOptionPane.showConfirmDialog( + null, + "Rename ${oldPath.fileName} to $aValue?", + ) == JOptionPane.YES_OPTION + ) { + if (Files.exists(oldPath)) { + Files.move(oldPath, newPath) + data[rowIndex].path = newPath + fireTableCellUpdated(rowIndex, columnIndex) + } else { + EventQueue.invokeLater { + JOptionPane.showMessageDialog( + null, + "Unable to rename file. Can't find path:\n${oldPath.absolutePathString()}", + ) + } + return + } + } + } + columns.Color -> { + data[rowIndex].color = aValue as Color? + fireTableCellUpdated(rowIndex, columnIndex) + } + columns.Show -> { + data[rowIndex].show = aValue as Boolean + fireTableCellUpdated(rowIndex, columnIndex) + } + } + } +} + +@Suppress("PropertyName") +internal class FileFilterColumns : ColumnList>() { + val Name: Column, Path> by column( + column = { + isEditable = true + cellRenderer = DefaultTableRenderer( + ReifiedLabelProvider( + getText = { it?.name }, + getTooltip = { it?.absolutePathString() + " (Double-click to rename)" }, + ), + ) + }, + value = { it.path }, + ) + + val Color by column( + column = { + isEditable = true + cellEditor = TableColorCellEditor() + minWidth = 80 + + setCellRenderer { _, value, _, _, _, _ -> + value as Color? + JLabel().apply { + isOpaque = value != null + text = value?.toHexString().orEmpty() + background = value + this@apply.toolTipText = "Click to change color" + } + } + }, + value = FileFilterConfigItem<*>::color, + ) + + val Show by column( + column = { + isEditable = true + minWidth = 25 + maxWidth = 25 + + headerRenderer = TableHeaderCheckbox() + isSortable = false + }, + value = FileFilterConfigItem<*>::show, + ) +} + +fun interface FileFilterChangeListener : EventListener { + fun fileFilterChanged() +} + +/** + * `FilterPanel`s should implement this interface in order to be compatible with FileFilterSidebar. + */ +interface FileFilterResponsive { + fun setModelData(data: List) +} + +/** + * Used by FileFilterSidebar to organize and filter in/out entire files. + */ +interface FileFilterableCollection { + val items: Collection +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterList.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterList.kt index cc10de79..e5470067 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterList.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterList.kt @@ -88,6 +88,22 @@ class FilterModel( companion object { private val percentFormat = DecimalFormat.getPercentInstance() + + fun fromRawData( + data: List, + comparator: FilterComparator, + sortKey: (R) -> String = Any?::toString, + transform: (T) -> R, + ): FilterModel { + val sortedData: Map = data.groupingBy(transform).eachCount().entries + .sortedWith( + compareBy(comparator) { (key, value) -> + FilterModelEntry(sortKey(key), value) + }, + ) + .associate(Map.Entry::toPair) + return FilterModel(sortedData, sortKey) + } } } @@ -100,6 +116,8 @@ class FilterList( ) : CheckBoxList(FilterModel(emptyMap())) { private var lastSelection = arrayOf() + var comparatorIsAdjusting = false + init { selectionModel = NoSelectionModel() isClickInCheckBoxOnly = false @@ -141,8 +159,10 @@ class FilterList( var comparator: FilterComparator = initialComparator set(newComparator) { - model = model.copy(newComparator) + comparatorIsAdjusting = true field = newComparator + model = model.copy(newComparator) + comparatorIsAdjusting = false } @Suppress("UNCHECKED_CAST") @@ -151,6 +171,8 @@ class FilterList( override fun setModel(model: ListModel<*>) { require(model is FilterModel<*>) val currentSelection = checkBoxListSelectedValues + val allSelected = currentSelection.size + 1 == this.model.size + lastSelection = if (currentSelection.isEmpty()) { lastSelection } else { @@ -162,7 +184,12 @@ class FilterList( for (sortAction in sortActions) { sortAction.selected = comparator == sortAction.comparator } - addCheckBoxListSelectedValues(lastSelection) + + if (allSelected) { + selectAll() + } else { + addCheckBoxListSelectedValues(lastSelection) + } } private val sortActions: List = FilterComparator.entries.map { filterComparator -> diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterListPanel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterListPanel.kt index 9f88bb26..436482a7 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterListPanel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterListPanel.kt @@ -18,7 +18,7 @@ abstract class FilterListPanel( init { filterList.checkBoxListSelectionModel.addListSelectionListener { e -> - if (!e.valueIsAdjusting) { + if (!e.valueIsAdjusting && !filterList.comparatorIsAdjusting) { listeners.getAll().forEach(FilterChangeListener::filterChanged) } } diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterSidebar.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterSidebar.kt index f2fe4ae2..c95627b2 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterSidebar.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/FilterSidebar.kt @@ -10,11 +10,9 @@ import javax.swing.JPopupMenu import javax.swing.JToolTip import javax.swing.UIManager -class FilterSidebar( - vararg panels: FilterPanel?, -) : FlatTabbedPane() { - - val filterPanels = panels.filterNotNull() +open class FilterSidebar( + private val filterPanels: List>, +) : FlatTabbedPane(), List> by filterPanels { override fun createToolTip(): JToolTip = JToolTip().apply { font = UIManager.getFont("h3.regular.font") @@ -45,28 +43,21 @@ class FilterSidebar( preferredSize = Dimension(250, 100) - filterPanels.forEachIndexed { index, filterPanel -> + filterPanels.forEach { filterPanel -> addTab( null, filterPanel.icon, filterPanel.component, - buildString { - tag("html") { - tag("p", "style" to "margin: 3px;") { - append(filterPanel.tabName) - } - } - }, + filterPanel.formattedTabName, ) filterPanel.addFilterChangeListener { filterPanel.updateTabState() - selectedIndex = index } } attachPopupMenu { event -> val tabIndex = indexAtLocation(event.x, event.y) - if (tabIndex == -1) return@attachPopupMenu null + if (tabIndex !in filterPanels.indices) return@attachPopupMenu null JPopupMenu().apply { val filterPanel = filterPanels[tabIndex] @@ -83,7 +74,7 @@ class FilterSidebar( selectedIndex = 0 } - private fun FilterPanel<*>.updateTabState() { + protected fun FilterPanel<*>.updateTabState() { val index = indexOfComponent(component) if (isFilterApplied()) { setBackgroundAt(index, UIManager.getColor("TabbedPane.focusColor")) @@ -99,4 +90,16 @@ class FilterSidebar( it.updateTabState() } } + + companion object { + @JvmStatic + protected val FilterPanel<*>.formattedTabName + get() = buildString { + tag("html") { + tag("p", "style" to "margin: 3px;") { + append(tabName) + } + } + } + } } 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..db46938e 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/General.kt @@ -1,5 +1,6 @@ package io.github.inductiveautomation.kindling.utils +import com.jidesoft.swing.CheckBoxListSelectionModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -129,3 +130,5 @@ infix fun InputStream.transferTo(output: OutputStream) { output.use(input::transferTo) } } + +fun CheckBoxListSelectionModel.isAllSelected() = isSelectedIndex(allEntryIndex) diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ReifiedTableModel.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ReifiedTableModel.kt index 87662a2c..0500a3f4 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ReifiedTableModel.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/ReifiedTableModel.kt @@ -2,8 +2,8 @@ package io.github.inductiveautomation.kindling.utils import javax.swing.table.AbstractTableModel -class ReifiedListTableModel( - val data: List, +open class ReifiedListTableModel( + open val data: List, override val columns: ColumnList, ) : AbstractTableModel(), ReifiedTableModel { override fun getColumnCount(): Int = columns.size diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StackTrace.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StackTrace.kt index 780e680a..80a9f68f 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StackTrace.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/StackTrace.kt @@ -35,7 +35,7 @@ fun StackElement.toBodyLine(version: String): BodyLine { } ?: BodyLine(this) } -@Suppress("ktlint:trailing-comma-on-declaration-site") +@Suppress("ktlint:standard:trailing-comma-on-declaration-site") enum class MajorVersion(val version: String) { SevenNine("7.9"), EightZero("8.0"), 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 14c96358..ea8b813f 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Swing.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Swing.kt @@ -6,7 +6,11 @@ import com.github.weisj.jsvg.attributes.ViewBox import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.swing.Swing +import org.jdesktop.swingx.decorator.ColorHighlighter +import org.jdesktop.swingx.decorator.ComponentAdapter +import org.jdesktop.swingx.decorator.HighlightPredicate import org.jdesktop.swingx.prompt.BuddySupport +import java.awt.Color import java.awt.Component import java.awt.Container import java.awt.RenderingHints @@ -68,7 +72,7 @@ fun FlatSVGIcon.asActionIcon(selected: Boolean = false): FlatSVGIcon { } } -fun JFileChooser.chooseFiles(parent: JComponent): List? { +fun JFileChooser.chooseFiles(parent: JComponent?): List? { return if (showOpenDialog(parent) == JFileChooser.APPROVE_OPTION) { selectedFiles.toList() } else { @@ -133,3 +137,32 @@ fun DocumentAdapter(block: (e: DocumentEvent) -> Unit): DocumentListener = objec override fun insertUpdate(e: DocumentEvent) = block(e) override fun removeUpdate(e: DocumentEvent) = block(e) } + +data class ColorPalette( + val background: Color?, + val foreground: Color?, +) { + fun toHighLighter( + predicate: (component: Component, componentAdapter: ComponentAdapter) -> Boolean = { _, _ -> true }, + ): ColorHighlighter { + return ColorHighlighter(background, foreground, predicate) + } +} + +fun ColorHighlighter( + background: Color?, + foreground: Color?, + predicate: (component: Component, componentAdapter: ComponentAdapter) -> Boolean = { _, _ -> true }, +) = ColorHighlighter(HighlightPredicate(predicate), background, foreground) + +@OptIn(ExperimentalStdlibApi::class) +fun Color.toHexString(alpha: Boolean = false): String { + val hexString = rgb.toHexString() + return "#${ + if (alpha) { + hexString + } else { + hexString.substring(2) + } + }" +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/TableColorCellEditor.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/TableColorCellEditor.kt new file mode 100644 index 00000000..dfdefb89 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/TableColorCellEditor.kt @@ -0,0 +1,129 @@ +package io.github.inductiveautomation.kindling.utils + +import com.jidesoft.icons.IconsFactory +import net.miginfocom.swing.MigLayout +import java.awt.Color +import java.awt.Component +import java.awt.EventQueue +import java.awt.event.MouseEvent +import java.util.EventObject +import javax.swing.AbstractCellEditor +import javax.swing.Icon +import javax.swing.JButton +import javax.swing.JColorChooser +import javax.swing.JComponent +import javax.swing.JDialog +import javax.swing.JLabel +import javax.swing.JTable +import javax.swing.colorchooser.AbstractColorChooserPanel +import javax.swing.table.TableCellEditor +import kotlin.random.Random + +/* + * A cell editor for editing table data which holds a java.awt.Color + */ +class TableColorCellEditor( + private val showHex: Boolean = true, // Whether to show the hex code in the table cell +) : AbstractCellEditor(), TableCellEditor { + private val label = JLabel() + private val colorChooser = JColorChooser() + + // Lazily initialized the first time a cell is edited. + private lateinit var dialog: JDialog + + init { + colorChooser.selectionModel.addChangeListener { + label.apply { + isOpaque = true + background = colorChooser.selectionModel.selectedColor + if (showHex) text = colorChooser.selectionModel.selectedColor.toHexString() + " (editing...)" + } + } + + colorChooser.addChooserPanel(RandomColorPanel()) + } + + override fun getCellEditorValue(): Color = colorChooser.color + + override fun isCellEditable(e: EventObject?): Boolean { + return e is MouseEvent && e.clickCount == 1 + } + + override fun getTableCellEditorComponent( + table: JTable?, + value: Any?, + isSelected: Boolean, + row: Int, + column: Int, + ): Component { + colorChooser.color = value as Color? + + val renderer = table?.getCellRenderer(row, column) + val component = renderer?.getTableCellRendererComponent(table, value, isSelected, true, row, column) + + if (component != null) { + label.apply { + isOpaque = value != null + background = component.background + + if (component is JComponent) border = component.border + if (showHex) text = component.background?.toHexString().orEmpty() + " (editing...)" + } + + if (!::dialog.isInitialized) { + dialog = JColorChooser.createDialog( + table, + "Choose a Color", + true, + colorChooser, + { _ -> stopCellEditing() }, // okListener + { _ -> cancelCellEditing() }, // cancelListener + ) + } + + EventQueue.invokeLater { + dialog.isVisible = true + } + } else { + label.isOpaque = false + } + return label + } +} + +class RandomColorPanel : AbstractColorChooserPanel() { + private val randomButton = JButton("Random Color") + private val previewLabel = JLabel().apply { + isOpaque = true + } + + override fun updateChooser() { + previewLabel.apply { + text = colorFromModel.toHexString() + background = colorFromModel + } + } + + override fun buildChooser() { + layout = MigLayout() + add(randomButton, "align 50% 50%, gapright 5px") + add(previewLabel, "align 50% 50%, w 50!, h 50!") + + randomButton.addActionListener { + val red = Random.nextInt(0, 0xFF) + val green = Random.nextInt(0, 0xFF) + val blue = Random.nextInt(0, 0xFF) + colorSelectionModel.selectedColor = Color(red, green, blue, 0x80) + } + } + + override fun getDisplayName(): String = "Random Color" + + override fun getSmallDisplayIcon(): Icon { + return IconsFactory.EMPTY_ICON + } + + override fun getLargeDisplayIcon(): Icon { + return IconsFactory.EMPTY_ICON + } +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/TableHeaderCheckbox.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/TableHeaderCheckbox.kt new file mode 100644 index 00000000..2e51f257 --- /dev/null +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/TableHeaderCheckbox.kt @@ -0,0 +1,111 @@ +package io.github.inductiveautomation.kindling.utils + +import java.awt.Component +import java.awt.EventQueue +import java.awt.event.MouseEvent +import java.awt.event.MouseListener +import javax.swing.JCheckBox +import javax.swing.JTable +import javax.swing.event.TableModelEvent +import javax.swing.event.TableModelListener +import javax.swing.table.JTableHeader +import javax.swing.table.TableCellRenderer + +class TableHeaderCheckbox( + selected: Boolean = true, +) : JCheckBox(), TableCellRenderer, MouseListener, TableModelListener { + private lateinit var table: JTable + private var targetColumn: Int? = null + private var valueIsAdjusting = false + + init { + isSelected = selected + toolTipText = "Select All" + + addActionListener { handleClick() } + } + + private val isAllDataSelected: Boolean + get() { + val column = targetColumn + if (!this::table.isInitialized || column == null) return false + + val columnModelIndex = table.convertColumnIndexToModel(column) + val columnClass = table.getColumnClass(column) + + if (columnClass != java.lang.Boolean::class.java) return false + + return table.model.rowIndices.all { + table.model.getValueAt(it, columnModelIndex) as? Boolean ?: return false + } + } + + private fun handleClick() { + valueIsAdjusting = true + + val column = targetColumn + if (!this::table.isInitialized || column == null) return + + val columnModelIndex = table.convertColumnIndexToModel(column) + val columnClass = table.getColumnClass(column) + + if (columnClass != java.lang.Boolean::class.java) return + + for (row in table.model.rowIndices) { + table.model.setValueAt(isSelected, row, columnModelIndex) + } + + valueIsAdjusting = false + } + + override fun tableChanged(e: TableModelEvent?) { + if (e == null || !::table.isInitialized) return + + val viewColumn = table.convertColumnIndexToView(e.column) + + if ((viewColumn == targetColumn || e.column == TableModelEvent.ALL_COLUMNS) && !valueIsAdjusting) { + isSelected = isAllDataSelected + + EventQueue.invokeLater { + table.tableHeader.repaint() + } + } + } + + override fun getTableCellRendererComponent( + table: JTable?, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + if (!this::table.isInitialized && table != null) { + this.table = table + + this.table.model.addTableModelListener(this) + this.table.tableHeader.addMouseListener(this) + } + + targetColumn = column + return this + } + + override fun mouseClicked(e: MouseEvent?) { + val tableHeader = e?.source as? JTableHeader ?: return + + val viewColumn = tableHeader.columnModel.getColumnIndexAtX(e.x) + val modelColumn = tableHeader.table.convertColumnIndexToModel(viewColumn) + + if (viewColumn == targetColumn && modelColumn != -1) { + doClick() + } + + tableHeader.repaint() + } + + override fun mousePressed(e: MouseEvent?) = Unit + override fun mouseReleased(e: MouseEvent?) = Unit + override fun mouseEntered(e: MouseEvent?) = Unit + override fun mouseExited(e: MouseEvent?) = Unit +} diff --git a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Tables.kt b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Tables.kt index 7775c49e..16856d33 100644 --- a/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Tables.kt +++ b/src/main/kotlin/io/github/inductiveautomation/kindling/utils/Tables.kt @@ -3,6 +3,8 @@ package io.github.inductiveautomation.kindling.utils import io.github.evanrupert.excelkt.workbook import java.io.File import javax.swing.JTable +import javax.swing.event.TableModelEvent +import javax.swing.table.AbstractTableModel import javax.swing.table.TableModel fun JTable.selectedRowIndices(): IntArray { @@ -23,6 +25,19 @@ fun JTable.selectedOrAllRowIndices(): IntArray { val TableModel.rowIndices get() = 0 until rowCount val TableModel.columnIndices get() = 0 until columnCount +/** + * A custom [TableModelEvent] which is fired when an unspecified number of row data has changed for a single column. + * + * @param column The column index. + */ +fun AbstractTableModel.fireTableColumnDataChanged(column: Int) { + fireTableChanged( + object : TableModelEvent(this) { + override fun getColumn(): Int = column + }, + ) +} + fun TableModel.exportToCSV(file: File) { file.printWriter().use { out -> columnIndices.joinTo(buffer = out, separator = ",") { col -> diff --git a/src/test/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogParsingTests.kt b/src/test/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogParsingTests.kt index 8f4e81f6..b31b2362 100644 --- a/src/test/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogParsingTests.kt +++ b/src/test/kotlin/io/github/inductiveautomation/kindling/log/WrapperLogParsingTests.kt @@ -163,7 +163,7 @@ class WrapperLogParsingTests : FunSpec( ) { companion object { fun parse(logs: String): List { - return WrapperLogView.parseLogs(logs.trimIndent().lineSequence()) + return WrapperLogPanel.parseLogs(logs.trimIndent().lineSequence()) } } }