From 687dde463720b69784d408229dcd30df7e61b5c4 Mon Sep 17 00:00:00 2001 From: Alexander Sysoev Date: Thu, 23 Oct 2025 16:16:17 +0200 Subject: [PATCH 1/2] undo TODO: - Selected node overview - A report form for plugins - Run configuration sync - Fix icons' popups - Unsafe fallback option - The Whole bundle must be from the same repository --- .../kotlinPlugins/KotlinPluginsActions.kt | 3 +- .../KotlinPluginsConfigurable.kt | 26 +- ...KotlinPluginsEditorNotificationProvider.kt | 9 +- .../KotlinPluginsExceptionAnalyzerService.kt | 23 +- .../KotlinPluginsExceptionReporter.kt | 126 ++- .../kotlinPlugins/KotlinPluginsJarLocator.kt | 57 +- .../KotlinPluginsNotificationBallon.kt | 10 +- .../KotlinPluginsNotifications.kt | 12 +- .../kotlinPlugins/KotlinPluginsSettings.kt | 4 + .../kotlinPlugins/KotlinPluginsStorage.kt | 172 ++-- .../KotlinPluginsToolWindowFactory.kt | 854 +++++++++++++++++- 11 files changed, 1180 insertions(+), 116 deletions(-) diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsActions.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsActions.kt index 257de01..db5bdcf 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsActions.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsActions.kt @@ -1,7 +1,6 @@ package com.github.mr3zee.kotlinPlugins import com.intellij.icons.AllIcons -import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -13,7 +12,7 @@ import javax.swing.JComponent open class KotlinPluginsClearCachesAction : AnAction(AllIcons.Actions.ClearCash) { override fun actionPerformed(e: AnActionEvent) { val project = e.project ?: return - val treeState = TreeState.getInstance(project) + val treeState = KotlinPluginsTreeState.getInstance(project) val clear = !treeState.showClearCachesDialog || run { val (clear, dontShowAgain) = ClearCachesDialog.show() diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsConfigurable.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsConfigurable.kt index f620a25..e6865c9 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsConfigurable.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsConfigurable.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.DialogWrapper @@ -51,7 +52,7 @@ private class LocalState { fun isModified( analyzer: KotlinPluginsExceptionAnalyzerState, - tree: TreeState, + tree: KotlinPluginsTreeState, settings: KotlinPluginsSettings.State, ): Boolean { return repositories != settings.repositories || @@ -64,7 +65,7 @@ private class LocalState { fun reset( analyzer: KotlinPluginsExceptionAnalyzerState, - tree: TreeState, + tree: KotlinPluginsTreeState, settings: KotlinPluginsSettings.State, ) { repositories.clear() @@ -83,7 +84,7 @@ private class LocalState { fun applyTo( analyzer: KotlinPluginsExceptionAnalyzerService, - tree: TreeState, + tree: KotlinPluginsTreeState, settings: KotlinPluginsSettings, ) { val enabledPlugins = plugins.map { @@ -101,6 +102,21 @@ private class LocalState { } class KotlinPluginsConfigurable(private val project: Project) : Configurable { + companion object { + @Volatile + private var selectArtifactsInitially: Boolean = false + + fun showArtifacts(project: Project) { + selectArtifactsInitially = true + ShowSettingsUtil.getInstance().showSettingsDialog(project, KotlinPluginsConfigurable::class.java) + } + + fun showGeneral(project: Project) { + selectArtifactsInitially = false + ShowSettingsUtil.getInstance().showSettingsDialog(project, KotlinPluginsConfigurable::class.java) + } + } + private val local: LocalState = LocalState() private lateinit var repoTable: JBTable @@ -303,6 +319,10 @@ class KotlinPluginsConfigurable(private val project: Project) : Configurable { val tabs = JBTabbedPane() tabs.addTab("General", generalContent) tabs.addTab("Artifacts", artifactsContent) + if (selectArtifactsInitially) { + tabs.selectedIndex = 1 + selectArtifactsInitially = false + } rootPanel = JPanel(BorderLayout()).apply { add(tabs, BorderLayout.NORTH) } reset() // initialise from a persisted state diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsEditorNotificationProvider.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsEditorNotificationProvider.kt index d71552d..57768f1 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsEditorNotificationProvider.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsEditorNotificationProvider.kt @@ -71,7 +71,14 @@ class KotlinPluginsEditorNotificationProvider : EditorNotificationProvider { } panel.createActionLabel("Open diagnostics") { - // todo update state to show error panel + val pluginOrNull = grouped.keys.singleOrNull() + + val jarIdOrNull = pluginOrNull?.let { grouped[it]?.singleOrNull() } + val mavenIdOrNull = jarIdOrNull?.mavenId + val versionOrNull = jarIdOrNull?.version + + KotlinPluginsTreeState.getInstance(project) + .select(pluginOrNull, mavenIdOrNull, versionOrNull) KotlinPluginsToolWindowFactory.show(project) } diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionAnalyzerService.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionAnalyzerService.kt index 62bc46d..1689c66 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionAnalyzerService.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionAnalyzerService.kt @@ -40,10 +40,11 @@ class KotlinPluginsExceptionAnalyzerService( Disposable { private val logger by lazy { thisLogger() } private val handler: AtomicReference = AtomicReference(null) - val supervisorJob = SupervisorJob(scope.coroutineContext.job) + private val supervisorJob = scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + private val publisherSync by lazy { project.messageBus.syncPublisher(KotlinPluginStatusUpdater.TOPIC) } override fun dispose() { - supervisorJob.cancel() + supervisorJob.job.cancel() val handler = handler.getAndSet(null) rootLogger().removeHandler(handler) handler?.close() @@ -59,6 +60,7 @@ class KotlinPluginsExceptionAnalyzerService( } project.service().start() + publisherSync.redraw() exceptionHandler.start() logger.debug("Exception analyzer started") rootLogger().addHandler(exceptionHandler) @@ -87,7 +89,13 @@ class KotlinPluginsExceptionAnalyzerService( ?: return@launch logger.debug("Exception detected for $ids: ${exception.message}") - reporter.matched(ids.toList(), exception, autoDisable = state.autoDisable) + + reporter.matched( + ids = ids.toList(), + exception = exception, + autoDisable = state.autoDisable, + isProbablyIncompatible = exception.isProbablyIncompatible(), + ) } } @@ -151,5 +159,14 @@ class KotlinPluginsExceptionAnalyzerService( .filter { it.isNotEmpty() } .reduce { acc, set -> acc.intersect(set) } } + + private fun Throwable.isProbablyIncompatible(): Boolean { + return this is ClassNotFoundException || + this is NoClassDefFoundError || + this is IncompatibleClassChangeError || + this is LinkageError || + this is ClassCastException || + this.cause?.isProbablyIncompatible() == true + } } } diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionReporter.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionReporter.kt index a700d89..e635306 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionReporter.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsExceptionReporter.kt @@ -30,11 +30,25 @@ interface KotlinPluginsExceptionReporter { suspend fun lookFor(): Map> - fun matched(ids: List, exception: Throwable, autoDisable: Boolean) + fun matched(ids: List, exception: Throwable, autoDisable: Boolean, isProbablyIncompatible: Boolean) fun hasExceptions(pluginName: String, mavenId: String, version: String): Boolean + + fun getExceptionsReport(pluginName: String, mavenId: String, version: String): ExceptionsReport? } +class ExceptionsReport( + // The jar is from a local repo + val isLocal: Boolean, + // The jar reloaded after to the same one exception occurred + val reloadedSame: Boolean, + // Exceptions that occurred point to the binary incompatibility + val isProbablyIncompatible: Boolean, + // The Kotlin version of the jar is different from the Kotlin version in the IDE + val kotlinVersionMismatch: KotlinVersionMismatch?, + val exceptions: List, +) + class KotlinPluginsExceptionReporterImpl( val project: Project, val scope: CoroutineScope, @@ -82,10 +96,27 @@ class KotlinPluginsExceptionReporterImpl( private val state = AtomicReference(null) private val stackTraceMap = ConcurrentHashMap>() + private val metadata = ConcurrentHashMap() + + private val caughtExceptions = ConcurrentHashMap>() + + private class CaughtException( + val jarId: JarId, + val exception: Throwable, + ) + + private data class JarMetadata( + val checksum: String, + val isLocal: Boolean, + val kotlinVersionMismatch: KotlinVersionMismatch?, + val reloadedSame: Boolean, + val isProbablyIncompatible: Boolean, + ) override fun dispose() { state.getAndSet(null)?.close() stackTraceMap.clear() + metadata.clear() } override fun start() { @@ -155,18 +186,11 @@ class KotlinPluginsExceptionReporterImpl( return stackTraceMap.toMap() } - private val caughtExceptions = mutableMapOf>() - - private class CaughtException( - val jarId: JarId, - val exception: Throwable, - ) - - override fun matched(ids: List, exception: Throwable, autoDisable: Boolean) { + override fun matched(ids: List, exception: Throwable, autoDisable: Boolean, isProbablyIncompatible: Boolean) { val settings = project.service() ids.groupBy { it.pluginName }.forEach { (pluginName, ids) -> - matched(settings, pluginName, ids, exception, autoDisable) + matched(settings, pluginName, ids, exception, autoDisable, isProbablyIncompatible) } } @@ -176,6 +200,7 @@ class KotlinPluginsExceptionReporterImpl( ids: List, exception: Throwable, autoDisable: Boolean, + isProbablyIncompatible: Boolean, ) { val plugin = settings.pluginByName(pluginName) ?: return @@ -188,6 +213,10 @@ class KotlinPluginsExceptionReporterImpl( } ids.forEach { id -> + metadata.compute(id) { _, old -> + old?.copy(isProbablyIncompatible = isProbablyIncompatible) + } + statusPublisher.updateVersion(id.pluginName, id.mavenId, id.version, ArtifactStatus.ExceptionInRuntime) } @@ -198,10 +227,15 @@ class KotlinPluginsExceptionReporterImpl( if (autoDisable) { settings.disablePlugins(plugin.name) - KotlinPluginsNotificationBallon.notify(project, disabledPlugin = plugin.name) + KotlinPluginsNotificationBallon.notify( + project = project, + disabledPlugin = plugin.name, + mavenId = ids.singleOrNull()?.mavenId, + version = ids.singleOrNull()?.version, + ) } else { // trigger editor notification across all Kotlin files - project.service().activate(plugin.name, ids.map { it.version }) + project.service().activate(ids) refreshNotifications() } } @@ -210,15 +244,69 @@ class KotlinPluginsExceptionReporterImpl( return caughtExceptions[pluginName].orEmpty().any { it.jarId.mavenId == mavenId && it.jarId.version == version } } + override fun getExceptionsReport(pluginName: String, mavenId: String, version: String): ExceptionsReport? { + val list = caughtExceptions[pluginName].orEmpty() + .filter { it.jarId.mavenId == mavenId && it.jarId.version == version } + .map { it.exception } + // Deduplicate by exception "signature": class + message + full stacktrace + val exceptions = list.distinctBy { ex -> + buildString { + append(ex::class.java.name) + append('|') + append(ex.message ?: "") + append('|') + append(ex.stackTraceToString()) + } + } + + return if (exceptions.isEmpty()) { + null + } else { + val metadata = metadata[JarId(pluginName, mavenId, version)] ?: return null + + ExceptionsReport( + exceptions = exceptions, + isLocal = metadata.isLocal, + reloadedSame = metadata.reloadedSame, + isProbablyIncompatible = metadata.isProbablyIncompatible, + kotlinVersionMismatch = metadata.kotlinVersionMismatch, + ) + } + } + private inner class DiscoveryHandler : KotlinPluginDiscoveryUpdater { override fun discoveredSync(discovery: KotlinPluginDiscoveryUpdater.Discovery) { - caughtExceptions.compute(discovery.pluginName) { _, old -> - old.orEmpty().filterNot { - it.jarId.mavenId == discovery.mavenId && it.jarId.version == discovery.version + val jarId = JarId(discovery.pluginName, discovery.mavenId, discovery.version) + + metadata.compute(jarId) { _, old -> + // the same checksum -> the same exception will be thrown eventually + if (old == null || old.checksum != discovery.checksum) { + caughtExceptions.compute(discovery.pluginName) { _, old -> + old.orEmpty().filterNot { + it.jarId == jarId + } + } + JarMetadata( + checksum = discovery.checksum, + isLocal = discovery.isLocal, + kotlinVersionMismatch = discovery.kotlinVersionMismatch, + reloadedSame = false, + isProbablyIncompatible = false, + ) + } else { + JarMetadata( + checksum = discovery.checksum, + isLocal = discovery.isLocal, + kotlinVersionMismatch = discovery.kotlinVersionMismatch, + reloadedSame = true, + isProbablyIncompatible = old.isProbablyIncompatible, + ) } } - project.service().deactivate(discovery.pluginName, discovery.version) + project.service() + .deactivate(discovery.pluginName, discovery.mavenId, discovery.version) + refreshNotifications() processDiscovery(discovery) @@ -227,10 +315,14 @@ class KotlinPluginsExceptionReporterImpl( override fun reset() { state.getAndSet(null)?.close() stackTraceMap.clear() - caughtExceptions.clear() } } + fun clearExceptions() { + caughtExceptions.clear() + metadata.clear() + } + private fun refreshNotifications() { ApplicationManager.getApplication().invokeLater { EditorNotifications.getInstance(project).updateAllNotifications() diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsJarLocator.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsJarLocator.kt index 5223f70..10f7e80 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsJarLocator.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsJarLocator.kt @@ -66,6 +66,8 @@ sealed interface LocatorResult { class Jar( val path: Path, val checksum: String, + val isLocal: Boolean, + val kotlinVersionMismatch: KotlinVersionMismatch?, ) internal object KotlinPluginsJarLocator { @@ -105,7 +107,7 @@ internal object KotlinPluginsJarLocator { versioned: VersionedKotlinPluginDescriptor, kotlinIdeVersion: String, dest: Path, - known: Map, + known: Map, ): BundleResult { val logTag = "[${versioned.descriptor.name}:${logId.andIncrement}]" @@ -115,10 +117,10 @@ internal object KotlinPluginsJarLocator { locatorResults = versioned.descriptor.ids.associateWith { mavenId -> locateArtifact( logTag = logTag, - versioned = RequestedKotlinPluginDescriptor(versioned.descriptor, versioned.version, mavenId,), + versioned = RequestedKotlinPluginDescriptor(versioned.descriptor, versioned.version, mavenId), kotlinIdeVersion = kotlinIdeVersion, dest = dest, - known = known[mavenId], + known = known[mavenId.id], ).also { val resultString = when (it) { is LocatorResult.Cached -> "Cached" @@ -392,7 +394,17 @@ internal object KotlinPluginsJarLocator { val finalFilename = jar.path.removeDownloadingExtension() Files.move(jar.path, finalFilename, StandardCopyOption.ATOMIC_MOVE) - LocatorResult.Cached(Jar(finalFilename, jar.checksum), filter, libVersion, original) + LocatorResult.Cached( + jar = Jar( + path = finalFilename, + checksum = jar.checksum, + isLocal = jar.isLocal, + kotlinVersionMismatch = jar.kotlinVersionMismatch, + ), + filter = filter, + libVersion = libVersion, + original = original, + ) } else { this } @@ -509,7 +521,7 @@ internal object KotlinPluginsJarLocator { return when (downloadResult) { is DownloadResult.Success -> LocatorResult.Cached( - jar = Jar(file, checksum), + jar = Jar(file, checksum, isLocal = false, kotlinVersionMismatch = null), filter = filter, libVersion = libVersion, ) @@ -573,7 +585,12 @@ internal object KotlinPluginsJarLocator { } } - return LocatorResult.Cached(Jar(file, checksum), filter, libVersion, jarPath) + return LocatorResult.Cached( + jar = Jar(path = file, checksum = checksum, isLocal = true, kotlinVersionMismatch = null), + filter = filter, + libVersion = libVersion, + original = jarPath + ) } private suspend fun getChecksum( @@ -619,14 +636,17 @@ internal object KotlinPluginsJarLocator { url = url, ) - val checksum = try { - temp.readBytes().asChecksum() - } catch (e: IOException) { - return ChecksumResult.FailedToFetch("Failed to read checksum file: ${e.message}") - } - return when (downloadResult) { - is DownloadResult.Success -> ChecksumResult.Success(checksum) + is DownloadResult.Success -> { + val checksum = try { + temp.readBytes().asChecksum() + } catch (e: IOException) { + return ChecksumResult.FailedToFetch("Failed to read checksum file: ${e.message}") + } + + ChecksumResult.Success(checksum) + } + is DownloadResult.NotFound -> ChecksumResult.NotFound is DownloadResult.FailedToFetch -> ChecksumResult.FailedToFetch(downloadResult.message) } @@ -682,6 +702,10 @@ internal object KotlinPluginsJarLocator { url: String, ): DownloadResult { val result = client.prepareGet(url).execute { httpResponse -> + val contentLength = httpResponse.contentLength()?.toDouble() ?: 0.0 + + logger.debug("$logTag Request URL: $url, status: ${httpResponse.status}, size: $contentLength") + if (httpResponse.status == HttpStatusCode.NotFound) { return@execute DownloadResult.NotFound } @@ -693,14 +717,9 @@ internal object KotlinPluginsJarLocator { ) } - val requestUrl = httpResponse.request.url.toString() - val contentLength = httpResponse.contentLength()?.toDouble() ?: 0.0 - - logger.debug("$logTag Request URL: $url, size: $contentLength") - if (contentLength == 0.0) { return@execute DownloadResult.FailedToFetch( - "Empty response body when downloading from $requestUrl" + "Empty response body when downloading from $url" ) } diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotificationBallon.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotificationBallon.kt index 77026bd..5b5e353 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotificationBallon.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotificationBallon.kt @@ -10,6 +10,8 @@ object KotlinPluginsNotificationBallon { fun notify( project: Project, disabledPlugin: String, + mavenId: String?, + version: String?, ) { NotificationGroupManager.getInstance() .getNotificationGroup("Kotlin External FIR Support") @@ -24,7 +26,13 @@ object KotlinPluginsNotificationBallon { e: AnActionEvent, notification: Notification, ) { - e.project?.let { KotlinPluginsToolWindowFactory.show(it) } + e.project?.let { + KotlinPluginsTreeState + .getInstance(it) + .select(disabledPlugin, mavenId, version) + + KotlinPluginsToolWindowFactory.show(it) + } notification.expire() } }) diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotifications.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotifications.kt index 4c48352..ba0ea45 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotifications.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsNotifications.kt @@ -9,17 +9,17 @@ import com.intellij.openapi.components.Service @Service(Service.Level.PROJECT) internal class KotlinPluginsNotifications { @Volatile - private var activePlugins: Set = emptySet() + private var activePlugins: Set = emptySet() - fun activate(pluginName: String, versions: List) { + fun activate(ids: List) { synchronized(this) { - activePlugins = activePlugins + versions.map { VersionedPluginKey(pluginName, it) } + activePlugins = activePlugins + ids } } - fun deactivate(pluginName: String, version: String) { + fun deactivate(pluginName: String, mavenId: String, version: String) { synchronized(this) { - activePlugins = activePlugins - VersionedPluginKey(pluginName, version) + activePlugins = activePlugins - JarId(pluginName, mavenId, version) } } @@ -27,5 +27,5 @@ internal class KotlinPluginsNotifications { synchronized(this) { activePlugins = emptySet() } } - fun currentPlugins(): Set = activePlugins + fun currentPlugins(): Set = activePlugins } diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsSettings.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsSettings.kt index 01bd358..1f36136 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsSettings.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsSettings.kt @@ -129,6 +129,10 @@ data class KotlinPluginDescriptor( } } +fun KotlinPluginDescriptor.hasArtifact(artifact: String): Boolean { + return ids.any { it.id == artifact } +} + data class MavenId(val id: String) { val groupId: String = id.substringBefore(":") val artifactId: String = id.substringAfter(":") diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsStorage.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsStorage.kt index 0e8b5f3..40a5e4a 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsStorage.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsStorage.kt @@ -90,6 +90,11 @@ interface KotlinPluginStatusUpdater { } } +class KotlinVersionMismatch( + val ideVersion: String, + val jarVersion: String, +) + interface KotlinPluginDiscoveryUpdater { fun discoveredSync(discovery: Discovery) fun reset() @@ -99,6 +104,9 @@ interface KotlinPluginDiscoveryUpdater { val mavenId: String, val version: String, val jar: Path, + val checksum: String, + val isLocal: Boolean, + val kotlinVersionMismatch: KotlinVersionMismatch?, ) companion object { @@ -107,8 +115,26 @@ interface KotlinPluginDiscoveryUpdater { } } -fun KotlinPluginDiscoveryUpdater.discoveredSync(pluginName: String, mavenId: String, version: String, jar: Path) { - discoveredSync(KotlinPluginDiscoveryUpdater.Discovery(pluginName, mavenId, version, jar)) +fun KotlinPluginDiscoveryUpdater.discoveredSync( + pluginName: String, + mavenId: String, + version: String, + jar: Path, + checksum: String, + isLocal: Boolean, + kotlinVersionMismatch: KotlinVersionMismatch?, +) { + discoveredSync( + KotlinPluginDiscoveryUpdater.Discovery( + pluginName = pluginName, + mavenId = mavenId, + version = version, + jar = jar, + checksum = checksum, + isLocal = isLocal, + kotlinVersionMismatch = kotlinVersionMismatch, + ) + ) } internal data class VersionedPluginKey( @@ -156,8 +182,7 @@ class KotlinPluginsStorage( private val logger by lazy { thisLogger() } - private val pluginsCache = - ConcurrentHashMap>>() + private val pluginsCache = ConcurrentHashMap>() private val statusPublisher by lazy { project.messageBus.syncPublisher(KotlinPluginStatusUpdater.TOPIC) @@ -276,8 +301,6 @@ class KotlinPluginsStorage( pluginsCache.clear() actualizerJobs.clear() indexJobs.clear() - discoveryPublisher.reset() - statusPublisher.reset() pluginWatchKeys.clear() pluginWatchKeysReverse.clear() originalWatchKeys.clear() @@ -374,7 +397,13 @@ class KotlinPluginsStorage( } if (detected.isNotEmpty()) { - logger.debug("File watcher detected changes in the cached path for $reversed: ${detected.joinToString { it.context().toString() }}") + logger.debug( + "File watcher detected changes in the cached path for $reversed: ${ + detected.joinToString { + it.context().toString() + } + }" + ) scope.actualize(VersionedKotlinPluginDescriptor(plugin, reversed.version)) } key.reset() @@ -390,14 +419,12 @@ class KotlinPluginsStorage( fun runActualization() { logger.debug("Requested actualization") - pluginsCache.values.forEach { - it.forEach { (pluginName, artifacts) -> - val plugin = project.service().pluginByName(pluginName) - ?: return@forEach + pluginsCache.forEach { (pluginName, artifacts) -> + val plugin = project.service().pluginByName(pluginName) + ?: return@forEach - artifacts.keys.forEach { artifact -> - scope.actualize(VersionedKotlinPluginDescriptor(plugin, artifact.libVersion)) - } + artifacts.keys.forEach { artifact -> + scope.actualize(VersionedKotlinPluginDescriptor(plugin, artifact.libVersion)) } } } @@ -427,21 +454,50 @@ class KotlinPluginsStorage( return forEachState { pluginName, mavenId, _, state -> if (state is ArtifactState.Cached) { - KotlinPluginDiscoveryUpdater.Discovery(pluginName, mavenId, state.actualVersion, state.jar.path) + KotlinPluginDiscoveryUpdater.Discovery( + pluginName = pluginName, + mavenId = mavenId, + version = state.actualVersion, + jar = state.jar.path, + checksum = state.jar.checksum, + isLocal = state.jar.isLocal, + kotlinVersionMismatch = state.jar.kotlinVersionMismatch, + ) } else { null } }.filterNotNull() } + fun getLocationFor(pluginName: String, mavenId: String?, version: String?): Path? { + if (mavenId != null && version != null) { + val pluginKey = ResolvedPluginKey(mavenId, version) + return (pluginsCache[pluginName]?.get(pluginKey) as? ArtifactState.Cached)?.jar?.path + } + + val kotlinIdeVersion = service().getKotlinIdePluginVersion() + return cacheDirBlocking() + ?.resolve(kotlinIdeVersion) + ?.resolve(pluginName) + ?.let { if (version != null) it.resolve(version) else it } + } + + fun getFailureMessageFor(pluginName: String, mavenId: String, version: String): String? { + val resolved = ResolvedPluginKey(mavenId, version) + + return when (val state = pluginsCache[pluginName]?.get(resolved)) { + is ArtifactState.FailedToFetch -> state.message + is ArtifactState.NotFound -> state.message + else -> null + } + } + private fun forEachState(doSomething: (String, String, String, ArtifactState) -> T): List { val result = mutableListOf() - pluginsCache.values.forEach { plugins -> - plugins.forEach { (pluginName, artifacts) -> - artifacts.entries.forEach { (artifact, state) -> - val item = doSomething(pluginName, artifact.mavenId.id, artifact.libVersion, state) - result.add(item) - } + pluginsCache.entries.forEach { (pluginName, artifacts) -> + artifacts.forEach { (artifact, state) -> + val item = doSomething(pluginName, artifact.mavenId, artifact.libVersion, state) + result.add(item) } } return result @@ -481,14 +537,13 @@ class KotlinPluginsStorage( val destination = pluginWatchKey.directory(kotlinIdeVersion) ?: return@launch val artifactsMap = pluginsCache - .getOrPut(kotlinIdeVersion) { ConcurrentHashMap() } .getOrPut(descriptor.name) { ConcurrentHashMap() } logger.debug("Actualize plugins job started (${plugin.descriptor.name}), attempt: $attempt") val known = artifactsMap.entries .filter { (key, value) -> - key.mavenId in plugin.descriptor.ids && key.libVersion == plugin.version && value is ArtifactState.Cached + plugin.descriptor.hasArtifact(key.mavenId) && key.libVersion == plugin.version && value is ArtifactState.Cached }.associate { (k, v) -> k.mavenId to (v as ArtifactState.Cached).jar } @@ -523,29 +578,17 @@ class KotlinPluginsStorage( failedToLocate = !bundle.allFound() bundle.locatorResults.entries.forEach { (id, locatorResult) -> - val resolvedKey = ResolvedPluginKey(id, locatorResult.libVersion) - val requestedKey = ResolvedPluginKey(id, plugin.version) - - fun updateMap(key: ResolvedPluginKey): Boolean { - var isNew = false - artifactsMap.compute(key) { _, old -> - val oldChecksum = (old as? ArtifactState.Cached)?.jar?.checksum - if (locatorResult is LocatorResult.Cached && locatorResult.jar.checksum != oldChecksum) { - isNew = true - anyJarChanged.compareAndSet(false, true) - } - - locatorResult.state + val resolvedKey = ResolvedPluginKey(id.id, locatorResult.libVersion) + + var resolvedIsNew = false + artifactsMap.compute(resolvedKey) { _, old -> + val oldChecksum = (old as? ArtifactState.Cached)?.jar?.checksum + if (locatorResult is LocatorResult.Cached && locatorResult.jar.checksum != oldChecksum) { + resolvedIsNew = true + anyJarChanged.compareAndSet(false, true) } - return isNew - } - - val resolvedIsNew = if (resolvedKey.libVersion != requestedKey.libVersion) { - updateMap(requestedKey) - updateMap(resolvedKey) - } else { - updateMap(resolvedKey) + locatorResult.state } if (locatorResult is LocatorResult.Cached) { @@ -577,6 +620,9 @@ class KotlinPluginsStorage( mavenId = id.id, version = locatorResult.state.actualVersion, jar = locatorResult.jar.path, + checksum = locatorResult.jar.checksum, + isLocal = locatorResult.jar.isLocal, + kotlinVersionMismatch = locatorResult.jar.kotlinVersionMismatch, ) } @@ -644,11 +690,12 @@ class KotlinPluginsStorage( fun getPluginPath(requested: RequestedKotlinPluginDescriptor): Path? { val kotlinIdeVersion = service().getKotlinIdePluginVersion() - val map = pluginsCache.getOrPut(kotlinIdeVersion) { ConcurrentHashMap() } - val pluginMap = map.getOrPut(requested.descriptor.name) { ConcurrentHashMap() } + val pluginMap = pluginsCache.getOrPut(requested.descriptor.name) { + ConcurrentHashMap() + } val paths = requested.descriptor.ids.map { - it.id to pluginMap[ResolvedPluginKey(it, requested.version)] as? ArtifactState.Cached? + it.id to pluginMap[ResolvedPluginKey(it.id, requested.version)] as? ArtifactState.Cached? } logger.debug( @@ -777,7 +824,7 @@ class KotlinPluginsStorage( kotlinIdeVersion: String, pluginWatchKey: VersionedPluginKey, ): StoredJar { - val resolvedPlugin = ResolvedPluginKey(requested.artifact, requested.version) + val resolvedPlugin = ResolvedPluginKey(requested.artifact.id, requested.version) val state = pluginMap.compute(resolvedPlugin) { _, old -> when { old is ArtifactState.Cached && Files.exists(old.jar.path) -> old @@ -793,7 +840,7 @@ class KotlinPluginsStorage( ) } - val (foundVersion, found) = findJarPath(requested, kotlinIdeVersion, pluginWatchKey) + val (foundVersion, foundKotlinVersion, found) = findJarPath(requested, kotlinIdeVersion, pluginWatchKey) ?: return StoredJar( mavenId = requested.artifact.id, locatedVersion = requested.version, @@ -836,9 +883,20 @@ class KotlinPluginsStorage( originalWatchKeysReverse[watchKeyWithChecksum.key] = jarKey } + val kotlinVersionMismatch = if (foundKotlinVersion != kotlinIdeVersion) { + KotlinVersionMismatch( + ideVersion = kotlinIdeVersion, + jarVersion = foundKotlinVersion, + ) + } else { + null + } + val jar = Jar( path = found, checksum = checksum, + isLocal = original != null, + kotlinVersionMismatch = kotlinVersionMismatch, ) val newState = ArtifactState.Cached( @@ -855,6 +913,9 @@ class KotlinPluginsStorage( mavenId = requested.artifact.id, version = requested.version, jar = jar.path, + checksum = checksum, + isLocal = jar.isLocal, + kotlinVersionMismatch = kotlinVersionMismatch, ) return StoredJar( @@ -868,7 +929,7 @@ class KotlinPluginsStorage( requested: RequestedKotlinPluginDescriptor, kotlinIdeVersion: String, pluginWatchKey: VersionedPluginKey, - ): Pair? = runCatching { + ): Triple? = runCatching { val basePath = pluginWatchKey.directoryBlocking(kotlinIdeVersion) ?: return null @@ -889,9 +950,14 @@ class KotlinPluginsStorage( .substringBefore(".jar") } - val latest = getMatching(versionToPath.keys.toList(), "", requested.asMatchFilter()) + val matched = getMatching(versionToPath.keys.toList(), "", requested.asMatchFilter()) + + return matched?.let { libVersion -> + val path = versionToPath.getValue(libVersion) - return latest?.let { it to versionToPath.getValue(it) } + // todo support fallbacks + Triple(libVersion, kotlinIdeVersion, path) + } }.getOrNull() @Suppress("UnstableApiUsage") @@ -927,7 +993,7 @@ class KotlinPluginsStorage( } private data class ResolvedPluginKey( - val mavenId: MavenId, + val mavenId: String, val libVersion: String, ) diff --git a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsToolWindowFactory.kt b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsToolWindowFactory.kt index f2ee71b..28e0d46 100644 --- a/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsToolWindowFactory.kt +++ b/src/main/kotlin/com/github/mr3zee/kotlinPlugins/KotlinPluginsToolWindowFactory.kt @@ -1,6 +1,7 @@ package com.github.mr3zee.kotlinPlugins import com.intellij.icons.AllIcons +import com.intellij.ide.actions.RevealFileAction import com.intellij.ide.projectView.PresentationData import com.intellij.ide.util.treeView.PresentableNodeDescriptor import com.intellij.openapi.Disposable @@ -11,6 +12,7 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.ToggleAction +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.EDT import com.intellij.openapi.components.BaseState import com.intellij.openapi.components.Service @@ -20,21 +22,42 @@ import com.intellij.openapi.components.Storage import com.intellij.openapi.components.StoragePathMacros.WORKSPACE_FILE import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea +import com.intellij.openapi.editor.markup.TextAttributes import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.ui.SimpleToolWindowPanel import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.NlsContexts import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.AnimatedIcon +import com.intellij.ui.EditorTextField import com.intellij.ui.OnePixelSplitter import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.TreeUIHelper +import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTabbedPane +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.AlignY +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.LabelPosition +import com.intellij.ui.dsl.builder.Panel +import com.intellij.ui.dsl.builder.Row +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.rows +import com.intellij.ui.dsl.listCellRenderer.listCellRenderer import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.tree.TreeUtil import kotlinx.coroutines.CoroutineName @@ -44,12 +67,30 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import java.awt.BorderLayout +import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.LayoutManager +import java.awt.font.TextAttribute +import java.util.function.Consumer +import javax.swing.BorderFactory +import javax.swing.Box +import javax.swing.DefaultListModel +import javax.swing.Icon import javax.swing.JComponent +import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.ListCellRenderer import javax.swing.ScrollPaneConstants +import javax.swing.SwingConstants +import javax.swing.SwingUtilities import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.TreeNode +import javax.swing.tree.TreePath +import javax.swing.tree.TreeSelectionModel import kotlin.coroutines.cancellation.CancellationException +import kotlin.io.path.isDirectory class KotlinPluginsToolWindowFactory : ToolWindowFactory, DumbAware { override fun createToolWindowContent( @@ -62,7 +103,7 @@ class KotlinPluginsToolWindowFactory : ToolWindowFactory, DumbAware { val toolWindowPanel = SimpleToolWindowPanel(false, true) - val state = TreeState.getInstance(project) + val state = KotlinPluginsTreeState.getInstance(project) val (panel, tree) = createDiagnosticsPanel(project, state) val splitter = OnePixelSplitter( @@ -72,9 +113,27 @@ class KotlinPluginsToolWindowFactory : ToolWindowFactory, DumbAware { /* maxProp = */ 0.7f, ).apply { firstComponent = panel - secondComponent = createPanel("Log tab is empty") } + // Overview + Logs tabs + val overviewPanel = OverviewPanel(project, state, tree) + tree.overviewPanel = overviewPanel + val tabs = JBTabbedPane() + tabs.addTab("Overview", overviewPanel.component) + tabs.addTab("Logs", createPanel("Logs tab is empty")) + splitter.secondComponent = tabs + + // Tree selection -> state + overview refresh + tree.addTreeSelectionListener { + val node = (tree.lastSelectedPathComponent as? DefaultMutableTreeNode) + val data = node?.userObject as? NodeData ?: return@addTreeSelectionListener + val key = data.key + state.selectedNodeKey = key + overviewPanel.updater.redraw() + } + // initial paint + overviewPanel.updater.redraw() + toolWindowPanel.setContent(splitter) val contentManager = toolWindow.contentManager @@ -82,6 +141,7 @@ class KotlinPluginsToolWindowFactory : ToolWindowFactory, DumbAware { Disposer.register(content) { tree.dispose() + overviewPanel.dispose() } contentManager.addContent(content) @@ -96,7 +156,7 @@ class KotlinPluginsToolWindowFactory : ToolWindowFactory, DumbAware { private fun createDiagnosticsPanel( project: Project, - state: TreeState, + state: KotlinPluginsTreeState, ): Pair { val panel = JPanel(BorderLayout()) val settings = project.service() @@ -211,6 +271,653 @@ class KotlinPluginsToolWindowFactory : ToolWindowFactory, DumbAware { } } +internal class OverviewPanel( + private val project: Project, + private val state: KotlinPluginsTreeState, + private val tree: KotlinPluginsTree, +) : Disposable { + val component: JPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(0, 0, 0, 0) + } + + val updater = object : KotlinPluginStatusUpdater { + override fun updatePlugin(pluginName: String, status: ArtifactStatus) { + refreshIfAffectsSelection(pluginName) + } + + override fun updateArtifact(pluginName: String, mavenId: String, status: ArtifactStatus) { + // artifact status will be recomputed from versions on demand + refreshIfAffectsSelection(pluginName, mavenId) + } + + override fun updateVersion(pluginName: String, mavenId: String, version: String, status: ArtifactStatus) { + refreshIfAffectsSelection(pluginName, mavenId, version) + } + + override fun reset() { + render() + } + + override fun redraw() { + render() + } + } + + init { + state.onSelectedState(::render) + render() + } + + private fun refreshIfAffectsSelection(pluginName: String, mavenId: String? = null, version: String? = null) { + val selectedKey = state.selectedNodeKey + val (p, m, v) = parseKey(selectedKey) + if (p == null) return + if (p != pluginName) return + if (mavenId == null || m == null || mavenId == m) { + if (version == null || v == null || version == v) { + SwingUtilities.invokeLater { render() } + } + } + } + + private fun parseKey(key: String?): Triple { + if (key == null) return Triple(null, null, null) + val parts = key.split("::") + return when (parts.size) { + 1 -> Triple(parts[0], null, null) + 2 -> Triple(parts[0], parts[1], null) + else -> Triple(parts[0], parts[1], parts[2]) + } + } + + private fun render() { + val selectedKey = state.selectedNodeKey + val status = tree.statusForKey(selectedKey) + val (plugin, mavenId, version) = parseKey(selectedKey) + + val parentPanel = JPanel(BorderLayout()).apply { + border = BorderFactory.createEmptyBorder(0, 0, 0, 0) + } + + fun addRoot(component: T, constraints: Any? = null): T { + if (constraints != null) { + parentPanel.add(component, constraints) + } else { + parentPanel.add(component) + } + return component + } + + fun addRootPanel(constraints: Any? = null, init: Panel.() -> Unit): JPanel { + return addRoot(panel(init), constraints) + } + + when { + plugin == null -> { + addRoot(GrayedLabel("Select a plugin to view details."), BorderLayout.CENTER) + .align(SwingConstants.CENTER) + } + + mavenId == null -> { + addRoot( + component = header( + type = NodeType.Plugin, + status = status, + plugin = plugin, + ), + constraints = BorderLayout.NORTH, + ) + + addRoot(pluginVersionPanels(NodeType.Plugin, status)) + } + + version == null -> { + addRoot( + component = header( + type = NodeType.Artifact, + status = status, + plugin = plugin, + mavenId = mavenId, + ), + constraints = BorderLayout.NORTH, + ) + + addRoot(pluginVersionPanels(NodeType.Artifact, status)) + } + + else -> { + addRoot( + component = header( + type = NodeType.Version, + status = status, + plugin = plugin, + mavenId = mavenId, + version = version, + ), + constraints = BorderLayout.NORTH, + ) + + when (status ?: ArtifactStatus.InProgress) { + ArtifactStatus.InProgress -> { + addRoot(inProgressPanel(NodeType.Version)) + } + + ArtifactStatus.Disabled -> { + addRoot(disabledPanel(NodeType.Version)) + } + + ArtifactStatus.Skipped -> { + addRoot(skippedPanel(NodeType.Version)) + } + + is ArtifactStatus.Success, is ArtifactStatus.ExceptionInRuntime -> { + val analyzer = project.service() + if (analyzer.state.enabled) { + addRootPanel { + val reporter = project.service() + val report = reporter.getExceptionsReport(plugin, mavenId, version) + + if (status is ArtifactStatus.ExceptionInRuntime && report != null) { + row { + label("Exception(s) occurred in runtime") + .comment( + """ + + A compiler plugin must never through an exception
+ Instead it must report errors using diagnostics
+ + """.trimIndent() + ) + } + + row { + val exceptionsAnalysis = when { + report.kotlinVersionMismatch != null && report.isProbablyIncompatible -> { + """ + Exceptions Analysis
+
+ The version of the plugin is not compatible with the version of the IDE.
+ This may be the cause for the exceptions thrown.
+ Compiler API is not binary compatible between Kotlin versions.
+
+ Indicated Kotlin version in the jar: ${report.kotlinVersionMismatch.jarVersion}
+ In IDE Kotlin version: ${report.kotlinVersionMismatch.ideVersion}
+
+ """.trimIndent() + } + + report.isProbablyIncompatible -> { + val kotlinIdeVersion = project.service() + .getKotlinIdePluginVersion() + + """ + Exceptions Analysis
+
+ Exceptions thrown are likely to be the sign of a binary incompatibility + between the Kotlin version of the IDE and the Kotlin version of the plugin.
+ The plugin indicates that the Kotlin version match, however this might not be the case.
+
+ In IDE Kotlin version: $kotlinIdeVersion
+
+ """.trimIndent() + } + + else -> "" + } + + val actionSuggestion = when { + report.reloadedSame && report.isLocal -> { + """ + Action suggestion
+
+ The jar was loaded from a local source + and was reloaded at least once with the same content so the exception persists.
+ If you are developing the plugin, try changing its logic + and republishing it locally to the same place. It will be updated automatically in IDE.
+ """.trimIndent() + } + + report.isLocal -> { + """ + Action suggestion
+
+ The jar was loaded from a local source.
+ If you are developing the plugin, try changing its logic + and republishing it locally to the same place. It will be updated automatically in IDE.
+ """.trimIndent() + } + + report.reloadedSame -> { + """ + Action suggestion
+
+ The jar was loaded from a remote source + and was reloaded at least once with the same content so the exception persists.
+ If you are developing the plugin, try changing its logic + and republishing it to the same place and run 'Update' action
+ """.trimIndent() + } + + report.kotlinVersionMismatch != null -> { + """ + Action suggestion
+
+ The jar was loaded unsafely, using a fallback without the compatibilities guarantee.
+ If you are developing the plugin, try publishing it with the same Kotlin version as the IDE.
+ If you are not the developer - you can report this problem to the plugin author using the button below.
+ """.trimIndent() + } + + else -> """ + Action suggestion
+
+ If you are developing the plugin - you need to check the exceptions and replace them with diagnostics.
+ If you are not the developer - you can report this problem to the plugin author using the button below.
+ """.trimIndent() + } + + val hint = """ + | + |$exceptionsAnalysis + |$actionSuggestion + | + """.trimMargin() + + label(hint) + .applyToComponent { + background = JBUI.CurrentTheme.Banner.INFO_BACKGROUND + border = JBUI.Borders.customLine( + /* color = */ JBUI.CurrentTheme.Banner.INFO_BORDER_COLOR, + /* top = */ 2, + /* left = */ 2, + /* bottom = */ 2, + /* right = */ 2, + ) + } + } + + row { + button("Create Report") { + // todo + } + } + + val exceptionPanes = mutableListOf>() + report.exceptions.forEachIndexed { index, ex -> + collapsibleGroup("#${index + 1}: ${ex::class.java.name}: ${ex.message ?: ""}") { + val (text, ranges) = exceptionTextAndHighlights(ex, emptySet()) + val editor = createExceptionEditor(text, ranges) + val scroll = ScrollPaneFactory.createScrollPane( + editor, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, + ) + + row { cell(scroll) } + exceptionPanes.add(editor to ex) + } + } + + loadFqNamesAsync(JarId(plugin, mavenId, version)) { names -> + ApplicationManager.getApplication().invokeLater { + exceptionPanes.forEach { (pane, throwable) -> + updateExceptionEditor(pane, throwable, names) + } + } + } + + separator() + } + + val model = DefaultListModel() + var list: JComponent? = null + row { + list = jbList( + label = "Analyzed classes in jar:", + icon = AllIcons.FileTypes.JavaClass, + labelFont = { it.deriveFont(Font.BOLD) }, + model = model, + renderer = @Suppress("UnstableApiUsage") listCellRenderer { + text(value) { + font = MonospacedFont + } + }, + ).apply { + // todo horizontally doesn't enable scroll bar, instead doesn't shrink + this.align(AlignX.FILL) + // todo vertically overflows + this.align(AlignY.FILL) + }.component + } + + var placeholder: JLabel? = null + row { placeholder = grayed("Loading analyzed classes...").component } + + loadFqNamesAsync(JarId(plugin, mavenId, version)) { names -> + ApplicationManager.getApplication().invokeLater { + if (names.isEmpty()) { + placeholder?.text = "No analyzed classes found" + list?.isVisible = false + } else { + placeholder?.isVisible = false + model.clear() + names.sorted().forEach { model.addElement(it) } + list?.isVisible = true + } + component.revalidate() + component.repaint() + } + } + } + } else { + addRoot(mainPanel { GridBagLayout() }).apply { + val label = GrayedLabel("Analysis for classes in a jar is disabled.") + + val actionLink = ActionLink("Open Settings") { + KotlinPluginsConfigurable.showGeneral(project) + } + + vertical(label, actionLink) + } + } + } + + is ArtifactStatus.FailedToLoad -> { + val sanitizedMessage = project.service() + .getFailureMessageFor(plugin, mavenId, version) + ?: "Failed to load with unknown reason.
Please check the log for details." + + addRootPanel { + var area: Cell? = null + row { + area = textArea() + .label("Failed to load this jar:", LabelPosition.TOP) + .align(AlignX.FILL) + .rows(10) + .applyToComponent { + text = sanitizedMessage + isEditable = false + wrapStyleWord = true + lineWrap = state.softWrapErrorMessages + } + } + + row { + checkBox("Soft wrap").applyToComponent { + addActionListener { + state.softWrapErrorMessages = isSelected + area?.component?.lineWrap = isSelected + } + } + } + } + } + } + } + } + + component.removeAll() + component.add(parentPanel, BorderLayout.CENTER) + component.revalidate() + component.repaint() + } + + private fun Row.grayed(@NlsContexts.Label text: String) = label(text).applyToComponent { + foreground = JBUI.CurrentTheme.Label.disabledForeground() + } + + @Suppress("FunctionName") + private fun GrayedLabel(@NlsContexts.Label text: String) = JBLabel(text).apply { + foreground = JBUI.CurrentTheme.Label.disabledForeground() + } + + private fun Row.bold(@NlsContexts.Label text: String) = label(text).applyToComponent { + font = font.deriveFont(font.style or Font.BOLD) + } + + private fun JLabel.align(alignment: Int): JLabel { + horizontalAlignment = alignment + return this + } + + private fun openCacheLink( + isFile: Boolean, + plugin: String, + mavenId: String? = null, + version: String? = null, + ): ActionLink { + return if (isFile) { + ActionLink("Show in cache directory") { revealFor(plugin, mavenId, version) } + } else { + ActionLink("Show cache directory") { revealFor(plugin, mavenId, version) } + }.apply { + icon = AllIcons.General.OpenDisk + } + } + + private fun header( + type: NodeType, + status: ArtifactStatus?, + plugin: String, + mavenId: String? = null, + version: String? = null, + ): JComponent { + return JPanel(BorderLayout()).apply { + val insets = JBUI.CurrentTheme.Toolbar.horizontalToolbarInsets() ?: JBUI.insets(5, 7) + border = BorderFactory.createEmptyBorder(insets.top, insets.left, insets.bottom, insets.right) + background = JBUI.CurrentTheme.ToolWindow.headerBackground() + + status?.let { + val label = JBLabel(statusToTooltip(type, status)).apply { + icon = statusToIcon(status) + } + add(label, BorderLayout.LINE_START) + } + + if (status != null && (status is ArtifactStatus.Success || status is ArtifactStatus.ExceptionInRuntime)) { + add(Box.createRigidArea(JBUI.size(8, 0)), BorderLayout.CENTER) + + add(openCacheLink(type == NodeType.Version, plugin, mavenId, version), BorderLayout.LINE_END) + } + } + } + + private fun mainPanel(layoutGetter: (JComponent) -> LayoutManager = { BorderLayout() }): JPanel { + return JPanel().apply { + layout = layoutGetter(this) + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + } + } + + private fun pluginVersionPanels( + type: NodeType, + status: ArtifactStatus?, + ): JPanel { + return when (status) { + ArtifactStatus.InProgress -> inProgressPanel(type) + ArtifactStatus.Disabled -> disabledPanel(type) + ArtifactStatus.Skipped -> skippedPanel(type) + else -> selectVersionPanel() + } + } + + private fun selectVersionPanel(): JPanel { + return mainPanel { GridBagLayout() }.apply { + val grayedLabel = GrayedLabel("Select a version to see details.") + + vertical(grayedLabel) + } + } + + private fun inProgressPanel(type: NodeType): JPanel { + return mainPanel { GridBagLayout() }.apply { + val text = GrayedLabel("Panel will display info when ${type.displayLowerCaseName} is loaded.") + + vertical(text) + } + } + + private fun skippedPanel(type: NodeType): JPanel { + return mainPanel { GridBagLayout() }.apply { + val text = GrayedLabel("This ${type.displayLowerCaseName} is not yet requested in project.") + val description = GrayedLabel("Resolution is lazy and it might be requested later.") + + vertical(text, description) + } + } + + private fun disabledPanel(type: NodeType): JPanel { + return mainPanel { GridBagLayout() }.apply { + val label = GrayedLabel("You can enable this ${type.displayLowerCaseName} in ") + + val actionLink = ActionLink("Settings") { + KotlinPluginsConfigurable.showArtifacts(project) + } + + horizontal(label, actionLink, rightInset = 0) + } + } + + private fun JPanel.vertical( + vararg components: JComponent, + ) { + val gbc = GridBagConstraints() + + components.forEachIndexed { i, component -> + gbc.gridx = 0 // Column 0 + gbc.gridy = i // Row i + if (i != components.lastIndex) { + gbc.insets.bottom = 7 + } + this.add(component, gbc) + } + } + + private fun JPanel.horizontal( + vararg components: JComponent, + rightInset: Int = 5, + ) { + val gbc = GridBagConstraints() + + components.forEachIndexed { i, component -> + gbc.gridx = i // Column i + gbc.gridy = 0 // Row 0 + if (i != components.lastIndex) { + gbc.insets.right = rightInset + } + this.add(component, gbc) + } + } + + private fun loadFqNamesAsync(jarId: JarId, onReady: (Set) -> Unit) { + val analyzer = project.service() + if (!analyzer.state.enabled) { + return + } + + project.service().treeScope.launch(CoroutineName("load-fqnames-$jarId")) { + val map = project.service().lookFor() + val names = map[jarId].orEmpty() + onReady(names) + } + } + + private fun revealFor(plugin: String, mavenId: String? = null, version: String? = null) { + val storage = project.service() + val path = storage.getLocationFor(plugin, mavenId, version) + if (path != null) { + runCatching { + if (path.isDirectory()) { + RevealFileAction.openDirectory(path) + } else { + RevealFileAction.openFile(path) + } + } + } + } + + private fun exceptionTextAndHighlights(ex: Throwable, highlights: Set): Pair> { + val raw = ex.stackTraceToString() + val lines = raw.split("\r\n", "\n") + val limit = lines.size.coerceAtMost(MAX_STACKTRACE_LINES) + val sb = StringBuilder() + val ranges = mutableListOf() + var offset = 0 + for (i in 0 until limit) { + val line = lines[i] + val shouldHighlight = if (line.startsWith("\tat ")) { + val body = line.removePrefix("\tat ") + val classPart = body.substringBefore('(').substringBeforeLast('.') + highlights.contains(classPart) + } else false + val toAppend = line + "\n" + if (shouldHighlight) { + ranges.add(offset until (offset + line.length)) + } + sb.append(toAppend) + offset += toAppend.length + } + if (lines.size > limit) { + val footer = "… truncated ${lines.size - limit} more lines\n" + sb.append(footer) + } + return sb.toString() to ranges + } + + private fun createExceptionEditor(text: String, highlightRanges: List): EditorTextField { + return object : EditorTextField(text, project, null) { + override fun createEditor(): EditorEx { + val ed = super.createEditor() + ed.isViewer = true + ed.settings.isLineNumbersShown = false + ed.settings.isFoldingOutlineShown = false + ed.settings.isCaretRowShown = false + ed.settings.isUseSoftWraps = true + ed.settings.isWhitespacesShown = false + applyHighlights(ed, highlightRanges) + return ed + } + } + } + + private fun applyHighlights(editor: com.intellij.openapi.editor.Editor, highlightRanges: List) { + val markup = editor.markupModel + markup.removeAllHighlighters() + val attrs = TextAttributes( + /* foregroundColor = */ null, + /* backgroundColor = */ JBUI.CurrentTheme.Editor.Tooltip.SUCCESS_BACKGROUND, + /* effectColor = */ null, + /* effectType = */ null, + /* fontType = */ Font.PLAIN, + ) + highlightRanges.forEach { range -> + markup.addRangeHighlighter( + range.first, + range.last + 1, + HighlighterLayer.SELECTION - 1, + attrs, + HighlighterTargetArea.EXACT_RANGE + ) + } + } + + private fun updateExceptionEditor(editorField: EditorTextField, ex: Throwable, fqNames: Set) { + val (text, ranges) = exceptionTextAndHighlights(ex, fqNames) + editorField.text = text + editorField.editor?.let { ed -> + applyHighlights(ed, ranges) + } + } + + companion object { + private const val MAX_STACKTRACE_LINES = 100 + } + + override fun dispose() { + state.removeOnSelectedState(::render) + } +} + private class NodeData( project: Project, val parent: NodeData?, @@ -268,16 +975,39 @@ private class NodeData( } } -class TreeState : BaseState() { +class KotlinPluginsTreeState : BaseState() { var showSucceeded: Boolean by property(true) var showSkipped: Boolean by property(true) var selectedNodeKey: String? by string(null) var showClearCachesDialog: Boolean by property(true) + var softWrapErrorMessages: Boolean by property(false) companion object { - fun getInstance(project: Project): TreeState = project.service().state + fun getInstance(project: Project): KotlinPluginsTreeState = + project.service().state + } + + fun select(pluginName: String? = null, mavenId: String? = null, version: String? = null) { + if (pluginName == null) { + selectedNodeKey = null + return + } + + val key = nodeKey(pluginName, mavenId, version) + selectedNodeKey = key + } + + private val actions = mutableListOf<() -> Unit>() + + fun onSelectedState(action: () -> Unit): () -> Unit { + actions.add(action) + return action + } + + fun removeOnSelectedState(action: () -> Unit) { + actions.remove(action) } } @@ -288,13 +1018,15 @@ class TreeState : BaseState() { ) class KotlinPluginTreeStateService( val treeScope: CoroutineScope, -) : SimplePersistentStateComponent(TreeState()) +) : SimplePersistentStateComponent(KotlinPluginsTreeState()) class KotlinPluginsTree( private val project: Project, - val state: TreeState, + val state: KotlinPluginsTreeState, val settings: KotlinPluginsSettings, ) : Tree(), Disposable { + internal var overviewPanel: OverviewPanel? = null + private val rootNode = DefaultMutableTreeNode( NodeData( project = project, @@ -309,6 +1041,15 @@ class KotlinPluginsTree( private val model = DefaultTreeModel(rootNode) private val nodesByKey: MutableMap = mutableMapOf() + + fun statusForKey(key: String?): ArtifactStatus? { + if (key == null) { + return null + } + + return nodesByKey[key]?.data?.status?.takeIf { shouldIncludeStatus(it) } + } + private val connection = project.messageBus.connect() private val updaters = Channel<() -> Unit>(Channel.UNLIMITED) @@ -340,21 +1081,32 @@ class KotlinPluginsTree( updater.reset() connection.subscribe(KotlinPluginStatusUpdater.TOPIC, updater) + selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION + } + + private fun TreeNode?.selectionPath(): List { + if (this == null) { + return emptyList() + } + + return this.parent.selectionPath() + this } private fun reset() { - nodesByKey.clear() + nodesByKey.values.removeIf { it.data.status !is ArtifactStatus.ExceptionInRuntime } + redrawModel() project.service().requestStatuses() } private fun redrawModel() { - state.selectedNodeKey = null - rootNode.removeAllChildren() val plugins = settings.safeState().plugins + val selectedNodeKey = state.selectedNodeKey + var selectedNode: DefaultMutableTreeNode? = null + for (plugin in plugins) { val rootData = rootNode.userObject as NodeData @@ -379,6 +1131,9 @@ class KotlinPluginsTree( }.map { (k, v) -> val versionNode = DefaultMutableTreeNode(v.data) nodesByKey[k] = versionNode + if (k == selectedNodeKey) { + selectedNode = versionNode + } versionNode } @@ -403,6 +1158,10 @@ class KotlinPluginsTree( if (shouldIncludeStatus(artifactStatus)) { pluginNode.add(artifactNode) versionNodes.forEach { artifactNode.add(it) } + + if (artifactKey == selectedNodeKey) { + selectedNode = artifactNode + } } nodesByKey[artifactKey] = artifactNode @@ -420,6 +1179,9 @@ class KotlinPluginsTree( // include a plugin if it passes filter (by its own status) or has any children if (shouldIncludeStatus(pluginStatus) || pluginNode.childCount > 0) { rootNode.add(pluginNode) + if (pluginKey == selectedNodeKey) { + selectedNode = pluginNode + } } nodesByKey[pluginKey] = pluginNode @@ -433,7 +1195,19 @@ class KotlinPluginsTree( criteria = KotlinPluginDescriptor.VersionMatching.EXACT, ) + if (selectedNodeKey != null && selectedNodeKey !in nodesByKey.keys) { + state.selectedNodeKey = null + } + model.reload() + + if (selectedNode != null) { + val path = selectedNode.selectionPath() + if (path.isNotEmpty()) { + selectionPath = TreePath(path.toTypedArray()) + } + } + repaint() } @@ -466,6 +1240,8 @@ class KotlinPluginsTree( status: ArtifactStatus, ) = updateUi { this@KotlinPluginsTree.updateArtifact(pluginName, mavenId, status) + + overviewPanel?.updater?.updateArtifact(pluginName, mavenId, status) } override fun updateVersion( @@ -475,18 +1251,24 @@ class KotlinPluginsTree( status: ArtifactStatus, ) = updateUi { this@KotlinPluginsTree.updateVersion(pluginName, mavenId, version, status) + + overviewPanel?.updater?.updateVersion(pluginName, mavenId, version, status) } override fun reset() = updateUi { this@KotlinPluginsTree.reset() expandAll() + + overviewPanel?.updater?.reset() } override fun redraw() = updateUi { this@KotlinPluginsTree.redrawModel() expandAll() + + overviewPanel?.updater?.redraw() } } @@ -516,6 +1298,7 @@ class KotlinPluginsTree( } } + // todo update logic for exceptions private fun updatePlugin(pluginName: String, status: ArtifactStatus) { val key = nodeKey(pluginName) @@ -677,7 +1460,7 @@ private fun statusToTooltip(type: NodeType, status: ArtifactStatus) = when (type "Version loaded successfully.
" + "Requested ${status.requestedVersion}, " + "actual is ${status.actualVersion}, " + - "criteria: ${status.criteria}" + "criteria: ${status.criteria}" } } @@ -720,3 +1503,52 @@ private fun KotlinPluginDescriptor.VersionMatching.toUi(): String { KotlinPluginDescriptor.VersionMatching.LATEST -> "Latest" } } + +private fun Row.jbList( + label: @NlsContexts.Label String?, + icon: Icon? = null, + labelFont: (Font) -> Font = { it }, + model: DefaultListModel, + renderer: ListCellRenderer, + patchList: Consumer>? = null, +): Cell { + val list = JBList(model) + list.setCellRenderer(renderer) + patchList?.accept(list) + val scroll = JBScrollPane( + list, + ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, + ) + scroll.isOverlappingScrollBar = true + + val result = cell(scroll) + label?.let { + val labelComponent = JBLabel(label).apply { + font = labelFont(font) + if (icon != null) { + this.icon = icon + } + } + + result.label(labelComponent, LabelPosition.TOP) + } + return result +} + +private val MonospacedFont by lazy { + val attributes = mutableMapOf() + + attributes[TextAttribute.FAMILY] = "Monospaced" + attributes[TextAttribute.WEIGHT] = TextAttribute.WEIGHT_REGULAR + attributes[TextAttribute.WIDTH] = TextAttribute.WIDTH_REGULAR + + JBFont.create(Font.getFont(attributes)).deriveFont(Font.PLAIN, 13.0f) +} + +private val NodeType.displayLowerCaseName + get() = when (this) { + NodeType.Plugin -> "plugin" + NodeType.Artifact -> "artifact" + NodeType.Version -> "version" + } From 6a0cafbb48b161e1bf43b718598788a8d392dc8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:13:25 +0000 Subject: [PATCH 2/2] Bump org.jetbrains.intellij.platform from 2.9.0 to 2.10.2 Bumps org.jetbrains.intellij.platform from 2.9.0 to 2.10.2. --- updated-dependencies: - dependency-name: org.jetbrains.intellij.platform dependency-version: 2.10.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ca85c1..9fc17d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ junit = "4.13.2" # plugins changelog = "2.2.1" -intelliJPlatform = "2.9.0" +intelliJPlatform = "2.10.2" kotlin = "2.2.20" kover = "0.9.1" qodana = "2025.1.1"