diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt index e31a74e57d9..f540bfeca8f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindowFactory.kt @@ -10,8 +10,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ex.ToolWindowEx -import com.intellij.ui.content.Content -import com.intellij.ui.content.ContentManager +import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.ui.components.BorderLayoutPanel import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection @@ -22,6 +22,8 @@ import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.core.credentials.sono.Q_SCOPES import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenAuthState import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.core.notifications.NotificationPanel +import software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBase import software.aws.toolkits.jetbrains.core.webview.BrowserState import software.aws.toolkits.jetbrains.services.amazonq.QWebviewPanel import software.aws.toolkits.jetbrains.services.amazonq.RefreshQChatPanelButtonPressedListener @@ -35,7 +37,21 @@ import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val mainPanel = BorderLayoutPanel() + val qPanel = Wrapper() + val notificationPanel = NotificationPanel() + + mainPanel.addToTop(notificationPanel) + mainPanel.add(qPanel) + val notifListener = ProcessNotificationsBase.getInstance(project) + notifListener.addListenerForNotification { bannerContent -> + runInEdt { + notificationPanel.updateNotificationPanel(bannerContent) + } + } + if (toolWindow is ToolWindowEx) { val actionManager = ActionManager.getInstance() toolWindow.setTitleActions(listOf(actionManager.getAction("aws.q.toolwindow.titleBar"))) @@ -46,7 +62,7 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { ToolkitConnectionManagerListener.TOPIC, object : ToolkitConnectionManagerListener { override fun activeConnectionChanged(newConnection: ToolkitConnection?) { - onConnectionChanged(project, newConnection, toolWindow) + onConnectionChanged(project, newConnection, qPanel) } } ) @@ -56,8 +72,7 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { object : RefreshQChatPanelButtonPressedListener { override fun onRefresh() { runInEdt { - contentManager.removeAllContents(true) - prepareChatContent(project, contentManager) + prepareChatContent(project, qPanel) } } } @@ -68,43 +83,37 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { object : BearerTokenProviderListener { override fun onChange(providerId: String, newScopes: List?) { if (ToolkitConnectionManager.getInstance(project).connectionStateForFeature(QConnection.getInstance()) == BearerTokenAuthState.AUTHORIZED) { - val content = contentManager.factory.createContent(AmazonQToolWindow.getInstance(project).component, null, false).also { - it.isCloseable = true - it.isPinnable = true - } + val qComponent = AmazonQToolWindow.getInstance(project).component runInEdt { - contentManager.removeAllContents(true) - contentManager.addContent(content) + qPanel.setContent(qComponent) } } } } ) - val content = prepareChatContent(project, contentManager) + prepareChatContent(project, qPanel) + val content = contentManager.factory.createContent(mainPanel, null, false).also { + it.isCloseable = true + it.isPinnable = true + } toolWindow.activate(null) - contentManager.setSelectedContent(content) + contentManager.addContent(content) } private fun prepareChatContent( project: Project, - contentManager: ContentManager, - ): Content { + qPanel: Wrapper, + ) { val component = if (isQConnected(project) && !isQExpired(project)) { AmazonQToolWindow.getInstance(project).component } else { QWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AmazonQ)) QWebviewPanel.getInstance(project).component } - - val content = contentManager.factory.createContent(component, null, false).also { - it.isCloseable = true - it.isPinnable = true - } - contentManager.addContent(content) - return content + qPanel.setContent(component) } override fun init(toolWindow: ToolWindow) { @@ -125,8 +134,7 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { override fun shouldBeAvailable(project: Project): Boolean = isQWebviewsAvailable() - private fun onConnectionChanged(project: Project, newConnection: ToolkitConnection?, toolWindow: ToolWindow) { - val contentManager = toolWindow.contentManager + private fun onConnectionChanged(project: Project, newConnection: ToolkitConnection?, qPanel: Wrapper) { val isNewConnectionForQ = newConnection?.let { (it as? AwsBearerTokenConnection)?.let { conn -> val scopeShouldHave = Q_SCOPES @@ -151,15 +159,8 @@ class AmazonQToolWindowFactory : ToolWindowFactory, DumbAware { LOG.debug { "returning login window; no Q connection found" } QWebviewPanel.getInstance(project).component } - - val content = contentManager.factory.createContent(component, null, false).also { - it.isCloseable = true - it.isPinnable = true - } - runInEdt { - contentManager.removeAllContents(true) - contentManager.addContent(content) + qPanel.setContent(component) } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/OuterAmazonQPanel.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/OuterAmazonQPanel.kt new file mode 100644 index 00000000000..72524e49b27 --- /dev/null +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/OuterAmazonQPanel.kt @@ -0,0 +1,39 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.toolwindow + +import com.intellij.openapi.project.Project +import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.webview.BrowserState +import software.aws.toolkits.jetbrains.services.amazonq.QWebviewPanel +import software.aws.toolkits.jetbrains.utils.isQConnected +import software.aws.toolkits.jetbrains.utils.isQExpired +import software.aws.toolkits.telemetry.FeatureId +import javax.swing.JComponent + +class OuterAmazonQPanel(val project: Project) : BorderLayoutPanel() { + private val wrapper = Wrapper() + init { + isOpaque = false + addToCenter(wrapper) + val component = if (isQConnected(project) && !isQExpired(project)) { + AmazonQToolWindow.getInstance(project).component + } else { + QWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AmazonQ)) + QWebviewPanel.getInstance(project).component + } + updateQPanel(component) + } + + fun updateQPanel(content: JComponent) { + try { + wrapper.setContent(content) + } catch (e: Exception) { + getLogger().error { "Error while creating window" } + } + } +} diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt index ce0603bf0fe..c4efe685884 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/RemoteResourceResolver.kt @@ -32,9 +32,11 @@ class DefaultRemoteResourceResolver( private fun internalResolve(resource: RemoteResource): Path { val expectedLocation = cacheBasePath.resolve(resource.name) val current = expectedLocation.existsOrNull() - if (current != null && !isExpired(current, resource)) { - LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." } - return current + if (resource.name != "notifications.json") { + if ((current != null && !isExpired(current, resource))) { + LOG.debug { "Existing file ($current) for ${resource.name} is present and not expired - using it." } + return current + } } LOG.debug { "Current file for ${resource.name} does not exist or is expired. Attempting to fetch from ${resource.urls}" } diff --git a/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml b/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml index 2ae2fdc75ab..ec52967c5b7 100644 --- a/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml +++ b/plugins/core/jetbrains-community/resources/META-INF/aws.toolkit.core.xml @@ -66,6 +66,7 @@ + @@ -77,6 +78,9 @@ restartRequired="true"/> + + diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/AwsToolkit.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/AwsToolkit.kt index 05d1e21e378..883c64a9051 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/AwsToolkit.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/AwsToolkit.kt @@ -28,6 +28,7 @@ object AwsToolkit { const val GITHUB_URL = "https://github.com/aws/aws-toolkit-jetbrains" const val AWS_DOCS_URL = "https://docs.aws.amazon.com/console/toolkit-for-jetbrains" + const val GITHUB_CHANGELOG = "https://github.com/aws/aws-toolkit-jetbrains/blob/main/CHANGELOG.md" } data class PluginInfo(val id: String, val name: String) { diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/CustomizeNotificationsUi.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/CustomizeNotificationsUi.kt new file mode 100644 index 00000000000..920ba519eac --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/CustomizeNotificationsUi.kt @@ -0,0 +1,132 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.ui.EditorNotificationPanel +import software.aws.toolkits.jetbrains.AwsPlugin +import software.aws.toolkits.jetbrains.AwsToolkit +import software.aws.toolkits.jetbrains.core.plugin.PluginUpdateManager +import software.aws.toolkits.resources.AwsCoreBundle + +fun checkSeverity(notificationSeverity: String): NotificationSeverity = when (notificationSeverity) { + "Critical" -> NotificationSeverity.CRITICAL + "Warning" -> NotificationSeverity.WARNING + "Info" -> NotificationSeverity.INFO + else -> NotificationSeverity.INFO +} + +object NotificationManager { + fun createActions( + project: Project, + followupActions: List?, + message: String, + title: String, + + ): List = buildList { + var url: String? = null + followupActions?.forEach { action -> + if (action.type == "ShowUrl") { + url = action.content.locale.url + } + + if (action.type == "UpdateExtension") { + add( + NotificationActionList(AwsCoreBundle.message("notification.update")) { + updatePlugins() + } + ) + } + + if (action.type == "OpenChangelog") { + add( + NotificationActionList(AwsCoreBundle.message("notification.changelog")) { + BrowserUtil.browse(AwsToolkit.GITHUB_CHANGELOG) + } + ) + } + } + add( + NotificationActionList(AwsCoreBundle.message("general.more_dialog")) { + if (url == null) { + Messages.showYesNoDialog( + project, + message, + title, + AwsCoreBundle.message("general.acknowledge"), + AwsCoreBundle.message("general.cancel"), + AllIcons.General.Error + ) + } else { + val link = url ?: AwsToolkit.GITHUB_URL + val openLink = Messages.showYesNoDialog( + project, + message, + title, + AwsCoreBundle.message(AwsCoreBundle.message("notification.learn_more")), + AwsCoreBundle.message("general.cancel"), + AllIcons.General.Error + ) + if (openLink == 0) { + BrowserUtil.browse(link) + } + } + } + ) + } + + fun buildNotificationActions(actions: List): List = actions.map { (title, block) -> + object : AnAction(title) { + override fun actionPerformed(e: AnActionEvent) { + block() + } + } + } + + fun buildBannerPanel(panel: EditorNotificationPanel, actions: List): EditorNotificationPanel { + actions.forEach { (actionTitle, block) -> + panel.createActionLabel(actionTitle) { + block() + } + } + + return panel + } + private fun updatePlugins() { + val pluginUpdateManager = PluginUpdateManager() + runInEdt { + ProgressManager.getInstance().run(object : Task.Backgroundable( + null, + AwsCoreBundle.message("aws.settings.auto_update.progress.message") + ) { + override fun run(indicator: ProgressIndicator) { + pluginUpdateManager.checkForUpdates(indicator, AwsPlugin.CORE) + pluginUpdateManager.checkForUpdates(indicator, AwsPlugin.TOOLKIT) + pluginUpdateManager.checkForUpdates(indicator, AwsPlugin.Q) + } + }) + } + } +} + +data class NotificationActionList( + val title: String, + val blockToExecute: () -> Unit, +) + +data class BannerContent( + val title: String, + val message: String, + val actions: List, + val id: String, +) diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/DisplayToastNotifications.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/DisplayToastNotifications.kt new file mode 100644 index 00000000000..870672f78a8 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/DisplayToastNotifications.kt @@ -0,0 +1,6 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +object DisplayToastNotifications diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt new file mode 100644 index 00000000000..ef0316b3187 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt @@ -0,0 +1,127 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.JsonNode + +class OperationConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.OperationCondition = when (parser.currentToken) { + JsonToken.VALUE_STRING -> { + // Handle direct string value + NotificationExpression.OperationCondition(parser.valueAsString) + } + else -> throw JsonMappingException(parser, "Cannot deserialize OperatingCondition") + } +} + +class ComparisonConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.ComparisonCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.ComparisonCondition(op.value) + } +} + +class NotEqualsConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.NotEqualsCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.NotEqualsCondition(op.value) + } +} +class GreaterThanConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.GreaterThanCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.GreaterThanCondition(op.value) + } +} +class GreaterThanOrEqualsConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.GreaterThanOrEqualsCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.GreaterThanOrEqualsCondition(op.value) + } +} +class LessThanConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.LessThanCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.LessThanCondition(op.value) + } +} +class LessThanOrEqualsConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.LessThanOrEqualsCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.LessThanOrEqualsCondition(op.value) + } +} +class ComplexOperationConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.ComplexOperationCondition { + val node = parser.codec.readTree(parser) + if (!node.isArray) { + throw JsonMappingException(parser, "anyOf/noneOf must contain an array of values") + } + val values = node.map { it.asText() } + return NotificationExpression.ComplexOperationCondition(values) + } +} +class AnyOfConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.AnyOfCondition { + val op = ComplexOperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.AnyOfCondition(op.value) + } +} + +class NoneOfConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.NoneOfCondition { + val op = ComplexOperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.NoneOfCondition(op.value) + } +} + +class ComplexConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.ComplexCondition { + val node = parser.codec.readTree(parser) + if (!node.isArray) { + throw JsonMappingException(parser, "or/and must contain an array of values") + } + return NotificationExpression.ComplexCondition(node.toNotificationExpressions(parser)) + } +} +class OrConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.OrCondition { + val op = ComplexConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.OrCondition(op.expectedValueList) + } +} + +class AndConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.AndCondition { + val op = ComplexConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.AndCondition(op.expectedValueList) + } +} + +class NotConditionDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationExpression.NotCondition { + val node = p.codec.readTree(p) + val parser = node.traverse(p.codec) + parser.nextToken() + + return NotificationExpression.NotCondition(parser.readValueAs(NotificationExpression::class.java)) + } +} + +// Create a custom deserializer if needed +class NotificationTypeDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationScheduleType = + NotificationScheduleType.fromString(p.valueAsString) +} + +private fun JsonNode.toNotificationExpressions(p: JsonParser): List = this.map { element -> + val parser = element.traverse(p.codec) + parser.nextToken() + parser.readValueAs(NotificationExpression::class.java) +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt new file mode 100644 index 00000000000..f582ce7d320 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt @@ -0,0 +1,200 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + +data class NotificationsList( + val schema: Schema, + val notifications: List?, +) + +data class Schema( + val version: String, +) + +data class NotificationData( + val id: String, + val schedule: NotificationSchedule, + val severity: String, + val condition: NotificationDisplayCondition?, + val content: NotificationContentDescriptionLocale, + val actions: List? = emptyList(), +) + +data class NotificationSchedule( + @JsonDeserialize(using = NotificationTypeDeserializer::class) + val type: NotificationScheduleType, +) { + constructor(type: String) : this(NotificationScheduleType.fromString(type)) +} + +enum class NotificationSeverity { + INFO, + WARNING, + CRITICAL, +} + +enum class NotificationScheduleType { + STARTUP, + EMERGENCY, + ; + + companion object { + fun fromString(value: String): NotificationScheduleType = + when (value.lowercase()) { + "startup" -> STARTUP + else -> EMERGENCY + } + } +} + +data class NotificationContentDescriptionLocale( + @JsonProperty("en-US") + val locale: NotificationContentDescription, +) + +data class NotificationContentDescription( + val title: String, + val description: String, +) + +data class NotificationFollowupActions( + val type: String, + val content: NotificationFollowupActionsContent, +) + +data class NotificationFollowupActionsContent( + @JsonProperty("en-US") + val locale: NotificationActionDescription, +) + +data class NotificationActionDescription( + val title: String, + val url: String?, +) + +data class NotificationDisplayCondition( + val compute: ComputeType?, + val os: SystemType?, + val ide: SystemType?, + val extension: List?, + val authx: List?, +) + +data class ComputeType( + val type: NotificationExpression?, + val architecture: NotificationExpression?, +) + +data class SystemType( + val type: NotificationExpression?, + val version: NotificationExpression?, +) + +data class ExtensionType( + val id: String?, + val version: NotificationExpression?, +) + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.WRAPPER_OBJECT +) +@JsonSubTypes( + JsonSubTypes.Type(value = NotificationExpression.ComparisonCondition::class, name = "=="), + JsonSubTypes.Type(value = NotificationExpression.NotEqualsCondition::class, name = "!="), + JsonSubTypes.Type(value = NotificationExpression.GreaterThanCondition::class, name = ">"), + JsonSubTypes.Type(value = NotificationExpression.GreaterThanOrEqualsCondition::class, name = ">="), + JsonSubTypes.Type(value = NotificationExpression.LessThanCondition::class, name = "<"), + JsonSubTypes.Type(value = NotificationExpression.LessThanOrEqualsCondition::class, name = "<="), + JsonSubTypes.Type(value = NotificationExpression.AnyOfCondition::class, name = "anyOf"), + JsonSubTypes.Type(value = NotificationExpression.NotCondition::class, name = "not"), + JsonSubTypes.Type(value = NotificationExpression.OrCondition::class, name = "or"), + JsonSubTypes.Type(value = NotificationExpression.AndCondition::class, name = "and"), + JsonSubTypes.Type(value = NotificationExpression.NoneOfCondition::class, name = "noneOf") +) +sealed interface NotificationExpression { + @JsonDeserialize(using = NotConditionDeserializer::class) + data class NotCondition( + val expectedValue: NotificationExpression, + ) : NotificationExpression + + @JsonDeserialize(using = OrConditionDeserializer::class) + data class OrCondition( + val expectedValueList: List, + ) : NotificationExpression + + @JsonDeserialize(using = AndConditionDeserializer::class) + data class AndCondition( + val expectedValueList: List, + ) : NotificationExpression + + @JsonDeserialize(using = ComplexConditionDeserializer::class) + data class ComplexCondition( + val expectedValueList: List, + ) : NotificationExpression + + // General class for comparison operators + @JsonDeserialize(using = OperationConditionDeserializer::class) + data class OperationCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = ComplexOperationConditionDeserializer::class) + data class ComplexOperationCondition( + val value: List, + ) : NotificationExpression + + @JsonDeserialize(using = ComparisonConditionDeserializer::class) + data class ComparisonCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = NotEqualsConditionDeserializer::class) + data class NotEqualsCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = GreaterThanConditionDeserializer::class) + data class GreaterThanCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = GreaterThanOrEqualsConditionDeserializer::class) + data class GreaterThanOrEqualsCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = LessThanConditionDeserializer::class) + data class LessThanCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = LessThanOrEqualsConditionDeserializer::class) + data class LessThanOrEqualsCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = AnyOfConditionDeserializer::class) + data class AnyOfCondition( + val value: List, + ) : NotificationExpression + + @JsonDeserialize(using = NoneOfConditionDeserializer::class) + data class NoneOfCondition( + val value: List, + ) : NotificationExpression +} + +data class AuthxType( + val feature: String, + val type: NotificationExpression?, + val region: NotificationExpression?, + val connectionState: NotificationExpression?, + val ssoScopes: NotificationExpression?, +) diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt new file mode 100644 index 00000000000..e9b6564adc8 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPanel.kt @@ -0,0 +1,40 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.icons.AllIcons +import com.intellij.openapi.application.runInEdt +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.resources.AwsCoreBundle + +class NotificationPanel : BorderLayoutPanel() { + private val wrapper = Wrapper() + init { + isOpaque = false + addToCenter(wrapper) + BannerNotificationService.getInstance().getNotifications().forEach { (_, content) -> + updateNotificationPanel(content) + } + } + + private fun removeNotificationPanel(notificationId: String) = runInEdt { + BannerNotificationService.getInstance().removeNotification(notificationId) + NotificationDismissalState.getInstance().dismissNotification(notificationId) + wrapper.removeAll() + } + + fun updateNotificationPanel(bannerContent: BannerContent) { + val panel = EditorNotificationPanel() + panel.text = bannerContent.title + panel.icon(AllIcons.General.Error) + val panelWithActions = NotificationManager.buildBannerPanel(panel, bannerContent.actions) + panelWithActions.createActionLabel(AwsCoreBundle.message("general.dismiss")) { + removeNotificationPanel(bannerContent.id) + } + + wrapper.setContent(panelWithActions) + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt new file mode 100644 index 00000000000..a52a9eb5c1c --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingService.kt @@ -0,0 +1,155 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.util.registry.Registry +import com.intellij.util.Alarm +import com.intellij.util.AlarmFactory +import com.intellij.util.io.HttpRequests +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import software.aws.toolkits.core.utils.RemoteResolveParser +import software.aws.toolkits.core.utils.RemoteResource +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.DefaultRemoteResourceResolverProvider +import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider +import software.aws.toolkits.telemetry.Component +import software.aws.toolkits.telemetry.ToolkitTelemetry +import java.io.InputStream +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean + +private const val MAX_RETRIES = 3 +private const val RETRY_DELAY_MS = 1000L + +object NotificationFileValidator : RemoteResolveParser { + override fun canBeParsed(data: InputStream): Boolean = + try { + NotificationMapperUtil.mapper.readValue(data) + true + } catch (e: Exception) { + false + } +} + +object NotificationEndpoint { + fun getEndpoint(): String = + Registry.get("aws.toolkit.notification.endpoint").asString() +} + +@Service(Service.Level.APP) +internal final class NotificationPollingService : Disposable { + private val isFirstPoll = AtomicBoolean(true) + private val observers = mutableListOf<() -> Unit>() + private val alarm = AlarmFactory.getInstance().create(Alarm.ThreadToUse.POOLED_THREAD, this) + private val pollingIntervalMs = Duration.ofMinutes(10).toMillis() + private val resourceResolver: RemoteResourceResolverProvider = DefaultRemoteResourceResolverProvider() + private val notificationsResource = object : RemoteResource { + override val name: String = "notifications.json" + override val urls: List = listOf(NotificationEndpoint.getEndpoint()) + override val remoteResolveParser: RemoteResolveParser = NotificationFileValidator + } + + fun startPolling() { + val newNotifications = runBlocking { pollForNotifications() } + isFirstPoll.set(false) + if (newNotifications) { + notifyObservers() + } + alarm.addRequest( + { startPolling() }, + pollingIntervalMs + ) + } + + /** + * Main polling function that checks for updates and downloads if necessary + * Returns the parsed notifications if successful, null otherwise + */ + private suspend fun pollForNotifications(): Boolean { + var retryCount = 0 + var lastException: Exception? = null + + while (retryCount < MAX_RETRIES) { + LOG.info { "Polling for notifications" } + try { + val newETag = getNotificationETag() + if (newETag == NotificationEtagState.getInstance().etag) { + // for when we need to notify on first poll even when there's no new ETag + if (isFirstPoll.compareAndSet(true, false)) { + LOG.info { "No new notifications, checking cached notifications on first poll" } + return true + } + LOG.info { "No new notifications to fetch" } + return false + } + resourceResolver.get() + .resolve(notificationsResource) + .toCompletableFuture() + .get() + NotificationEtagState.getInstance().etag = newETag + LOG.info { "New notifications fetched" } + return true + } catch (e: Exception) { + lastException = e + LOG.error(e) { "Failed to poll for notifications (attempt ${retryCount + 1}/$MAX_RETRIES)" } + retryCount++ + if (retryCount < MAX_RETRIES) { + val backoffDelay = RETRY_DELAY_MS * (1L shl (retryCount - 1)) + delay(backoffDelay) + } + } + } + emitFailureMetric(lastException) + return false + } + + private fun getNotificationETag(): String = + try { + HttpRequests.request(NotificationEndpoint.getEndpoint()) + .userAgent("AWS Toolkit for JetBrains") + .connect { request -> + request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() + } + } catch (e: Exception) { + LOG.warn { "Failed to fetch notification ETag: $e.message" } + throw e + } + + private fun emitFailureMetric(e: Exception?) { + ToolkitTelemetry.showNotification( + project = null, + component = Component.Filesystem, + id = "", + reason = "Failed to poll for notifications", + success = false, + reasonDesc = "${e?.javaClass?.simpleName ?: "Unknown"}: ${e?.message ?: "No message"}", + ) + } + + fun addObserver(observer: () -> Unit) = observers.add(observer) + + private fun notifyObservers() { + observers.forEach { observer -> + observer() + } + } + + override fun dispose() { + alarm.dispose() + } + + companion object { + private val LOG = getLogger() + fun getInstance(): NotificationPollingService = + ApplicationManager.getApplication().getService(NotificationPollingService::class.java) + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt new file mode 100644 index 00000000000..c8b84909ce5 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationServiceInitializer.kt @@ -0,0 +1,22 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import java.util.concurrent.atomic.AtomicBoolean + +internal class NotificationServiceInitializer : ProjectActivity { + + private val initialized = AtomicBoolean(false) + + override suspend fun execute(project: Project) { + if (ApplicationManager.getApplication().isUnitTestMode) return + if (initialized.compareAndSet(false, true)) { + val service = NotificationPollingService.getInstance() + service.startPolling() + } + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt new file mode 100644 index 00000000000..ce53118e249 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationStateUtils.kt @@ -0,0 +1,87 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.RoamingType +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service +@State(name = "notificationDismissals", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class NotificationDismissalState : PersistentStateComponent { + private val state = NotificationDismissalConfiguration() + + override fun getState(): NotificationDismissalConfiguration = state + + override fun loadState(state: NotificationDismissalConfiguration) { + this.state.dismissedNotificationIds.clear() + this.state.dismissedNotificationIds.addAll(state.dismissedNotificationIds) + } + + fun isDismissed(notificationId: String): Boolean = + state.dismissedNotificationIds.contains(notificationId) + + fun dismissNotification(notificationId: String) { + state.dismissedNotificationIds.add(notificationId) + } + + companion object { + fun getInstance(): NotificationDismissalState = + service() + } +} + +data class NotificationDismissalConfiguration( + var dismissedNotificationIds: MutableSet = mutableSetOf(), +) + +@Service +@State(name = "notificationEtag", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)]) +class NotificationEtagState : PersistentStateComponent { + private val state = NotificationEtagConfiguration() + + override fun getState(): NotificationEtagConfiguration = state + + override fun loadState(state: NotificationEtagConfiguration) { + this.state.etag = state.etag + } + + var etag: String? + get() = state.etag + set(value) { + state.etag = value + } + + companion object { + fun getInstance(): NotificationEtagState = + service() + } +} + +data class NotificationEtagConfiguration( + var etag: String? = null, +) + +@Service +class BannerNotificationService { + private val notifications = mutableMapOf() + + fun addNotification(id: String, content: BannerContent) { + notifications[id] = content + } + + fun getNotifications(): Map = notifications + + fun removeNotification(id: String) { + notifications.remove(id) + } + + companion object { + fun getInstance(): BannerNotificationService = + service() + } +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt new file mode 100644 index 00000000000..e759d9d079f --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt @@ -0,0 +1,120 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.inputStream +import software.aws.toolkits.jetbrains.utils.notifyStickyWithData +import java.nio.file.Paths +import java.util.concurrent.atomic.AtomicBoolean + +object NotificationMapperUtil { + val mapper: ObjectMapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) +} +private var isStartup: AtomicBoolean = AtomicBoolean(true) + +@Service(Service.Level.PROJECT) +class ProcessNotificationsBase( + private val project: Project, +) { + private val notifListener = mutableListOf() + init { + NotificationPollingService.getInstance().addObserver { + retrieveStartupAndEmergencyNotifications() + } + } + + private fun getNotificationsFromFile(): NotificationsList? { + val path = Paths.get(PathManager.getSystemPath(), NOTIFICATIONS_PATH) + val content = path.inputStream().bufferedReader().use { it.readText() } + if (content.isEmpty()) { + return null + } + return NotificationMapperUtil.mapper.readValue(content) + } + + fun retrieveStartupAndEmergencyNotifications() { + val isStartupPoll = isStartup.compareAndSet(true, false) + LOG.info { "Retrieving notifications for processing. StartUp notifications included: $isStartupPoll" } + val notifications = getNotificationsFromFile() + notifications?.let { notificationsList -> + val activeNotifications = notificationsList.notifications + ?.filter { notification -> + // Keep notification if: + // - it's not a startup notification, OR + // - it is a startup notification AND this is the first poll + notification.schedule.type != NotificationScheduleType.STARTUP || isStartupPoll + } + ?.filter { notification -> + !NotificationDismissalState.getInstance().isDismissed(notification.id) + } + .orEmpty() + + activeNotifications.forEach { processNotification(project, it) } + } + LOG.info { "Finished processing notifications" } + } + + fun processNotification(project: Project, notificationData: NotificationData) { + val shouldShow = RulesEngine.displayNotification(project, notificationData) + if (shouldShow) { + LOG.info { "Showing notification with id: ${notificationData.id}" } + val notificationContent = notificationData.content.locale + val severity = notificationData.severity + val followupActions = NotificationManager.createActions( + project, + notificationData.actions, + notificationContent.description, + notificationContent.title + ) + showToast( + notificationContent.title, + notificationContent.description, + NotificationManager.buildNotificationActions(followupActions), + checkSeverity(severity), + notificationData.id + ) + if (severity == "Critical") { + val bannerContent = BannerContent(notificationContent.title, notificationContent.description, followupActions, notificationData.id) + BannerNotificationService.getInstance().addNotification(notificationData.id, bannerContent) + notifyListenerForNotification(bannerContent) + } + } + } + + private fun showToast(title: String, message: String, action: List, notificationType: NotificationSeverity, notificationId: String) { + val notifyType = when (notificationType) { + NotificationSeverity.CRITICAL -> NotificationType.ERROR + NotificationSeverity.WARNING -> NotificationType.WARNING + NotificationSeverity.INFO -> NotificationType.INFORMATION + } + notifyStickyWithData(notifyType, title, message, null, action, notificationId) + } + + fun notifyListenerForNotification(bannerContent: BannerContent) = + notifListener.forEach { it(bannerContent) } + + fun addListenerForNotification(newNotifListener: NotifListener) = + notifListener.add(newNotifListener) + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ProcessNotificationsBase = project.service() + + private const val NOTIFICATIONS_PATH = "aws-static-resources/notifications.json" + } +} + +typealias NotifListener = (bannerContent: BannerContent) -> Unit diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt new file mode 100644 index 00000000000..bfb54952481 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt @@ -0,0 +1,209 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnectionType +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkIamProfileByCredentialType +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend + +object RulesEngine { + + fun displayNotification(project: Project, notification: NotificationData): Boolean { + // If no conditions provided, show display the notification to everyone + val shouldShow = notification.condition?.let { matchesAllRules(it, project) } ?: true + return shouldShow + } + + fun matchesAllRules(notificationConditions: NotificationDisplayCondition, project: Project): Boolean { + val sysDetails = getCurrentSystemAndConnectionDetails() + // If any of these conditions are null, we assume the condition to be true + val compute = notificationConditions.compute?.let { matchesCompute(it, sysDetails.computeType, sysDetails.computeArchitecture) } ?: true + val os = notificationConditions.os?.let { matchesOs(it, sysDetails.osType, sysDetails.osVersion) } ?: true + val ide = notificationConditions.ide?.let { matchesIde(it, sysDetails.ideType, sysDetails.ideVersion) } ?: true + val extension = matchesExtension(notificationConditions.extension, sysDetails.pluginVersions) + val authx = matchesAuth(notificationConditions.authx, project) + return compute && os && ide && extension && authx + } + + private fun matchesCompute(notificationCompute: ComputeType, actualCompute: String, actualArchitecture: String): Boolean { + val type = notificationCompute.type?.let { evaluateNotificationExpression(it, actualCompute) } ?: true + val architecture = notificationCompute.architecture?.let { evaluateNotificationExpression(it, actualArchitecture) } ?: true + return type && architecture + } + + private fun matchesOs(notificationOs: SystemType, actualOs: String, actualOsVersion: String): Boolean { + val os = notificationOs.type?.let { evaluateNotificationExpression(it, actualOs) } ?: true + val osVersion = notificationOs.version?.let { evaluateNotificationExpression(it, actualOsVersion) } ?: true + return os && osVersion + } + + private fun matchesIde(notificationIde: SystemType, actualIde: String, actualIdeVersion: String): Boolean { + val ide = notificationIde.type?.let { evaluateNotificationExpression(it, actualIde) } ?: true + val ideVersion = notificationIde.version?.let { evaluateNotificationExpression(it, actualIdeVersion) } ?: true + return ide && ideVersion + } + + private fun matchesExtension(notificationExtension: List?, actualPluginVersions: Map): Boolean { + if (notificationExtension.isNullOrEmpty()) return true + val extensionsToBeChecked = notificationExtension.map { it.id } + val pluginVersions = actualPluginVersions.filterKeys { extensionsToBeChecked.contains(it) } + return notificationExtension.all { extension -> + val actualVersion = pluginVersions[extension.id] + if (actualVersion == null) { + true + } else { + extension.version?.let { evaluateNotificationExpression(it, actualVersion) } ?: true + } + } + } + + private fun matchesAuth(notificationAuth: List?, project: Project): Boolean { + if (notificationAuth.isNullOrEmpty()) return true + return notificationAuth.all { feature -> + val actualConnection = when (feature.feature) { + "q" -> getConnectionDetailsForFeature(project, BearerTokenFeatureSet.Q) + "codeCatalyst" -> getConnectionDetailsForFeature(project, BearerTokenFeatureSet.CODECATALYST) + "toolkit" -> getConnectionDetailsForToolkit(project) + else -> return true + } + + if (actualConnection == null) { + false + } else { + val authType = feature.type?.let { evaluateNotificationExpression(it, actualConnection.connectionType) } ?: true + val authRegion = feature.region?.let { evaluateNotificationExpression(it, actualConnection.region) } ?: true + val connectionState = feature.connectionState?.let { evaluateNotificationExpression(it, actualConnection.connectionState) } ?: true + // TODO: Add condition for matching scopes + authType && authRegion && connectionState + } + } + } + + private fun evaluateNotificationExpression(notificationExpression: NotificationExpression, value: String): Boolean = when (notificationExpression) { + is NotificationExpression.NotCondition -> performNotOp(notificationExpression, value) + is NotificationExpression.OrCondition -> performOrOp(notificationExpression, value) + is NotificationExpression.AndCondition -> performAndOp(notificationExpression, value) + is NotificationExpression.ComparisonCondition -> notificationExpression.value == value + is NotificationExpression.NotEqualsCondition -> notificationExpression.value != value + is NotificationExpression.GreaterThanCondition -> value > notificationExpression.value + is NotificationExpression.LessThanCondition -> value < notificationExpression.value + is NotificationExpression.GreaterThanOrEqualsCondition -> value >= notificationExpression.value + is NotificationExpression.LessThanOrEqualsCondition -> value <= notificationExpression.value + is NotificationExpression.AnyOfCondition -> notificationExpression.value.contains(value) + is NotificationExpression.NoneOfCondition -> !notificationExpression.value.contains(value) + else -> true + } + + private fun performNotOp(notificationOperation: NotificationExpression.NotCondition, actualValue: String): Boolean = + !evaluateNotificationExpression(notificationOperation.expectedValue, actualValue) + + private fun performOrOp(notificationOperation: NotificationExpression.OrCondition, actualValue: String): Boolean = + notificationOperation.expectedValueList.any { evaluateNotificationExpression(it, actualValue) } + + private fun performAndOp(notificationOperation: NotificationExpression.AndCondition, actualValue: String): Boolean = + notificationOperation.expectedValueList.all { evaluateNotificationExpression(it, actualValue) } +} + +fun getCurrentSystemAndConnectionDetails(): SystemDetails { + val computeType: String = if (isRunningOnRemoteBackend()) "Remote" else "Local" + val computeArchitecture: String = SystemInfo.OS_ARCH + + val osType: String = SystemInfo.OS_NAME + val osVersion: String = SystemInfo.OS_VERSION + + val ideInfo = ApplicationInfo.getInstance() + val ideType: String = ideInfo.build.productCode + val ideVersion = ideInfo.shortVersion + + val pluginVersionMap = createPluginVersionMap() + + return SystemDetails(computeType, computeArchitecture, osType, osVersion, ideType, ideVersion, pluginVersionMap) +} + +data class FeatureAuthDetails( + val connectionType: String, + val region: String, + val connectionState: String, +) + +data class SystemDetails( + val computeType: String, + val computeArchitecture: String, + val osType: String, + val osVersion: String, + val ideType: String, + val ideVersion: String, + val pluginVersions: Map, +) + +fun createPluginVersionMap(): Map { + val pluginVersionMap = mutableMapOf() + val pluginIds = listOf( + "amazon.q", + "aws.toolkit.core", + "aws.toolkit" + ) + pluginIds.forEach { pluginId -> + val plugin = PluginManagerCore.getPlugin(PluginId.getId(pluginId)) + val pluginVersion = plugin?.version + if (pluginVersion != null) { + pluginVersionMap[pluginId] = pluginVersion + } + } + return pluginVersionMap +} + +private fun getConnectionDetailsForToolkit(project: Project): FeatureAuthDetails? { + val connection = checkIamProfileByCredentialType(project) + if (connection.activeConnectionIam == null) return null + val authType = when (connection.connectionType) { + ActiveConnectionType.IAM_IDC -> "Idc" + ActiveConnectionType.IAM -> "Iam" + else -> "Unknown" + } + val authRegion = connection.activeConnectionIam?.defaultRegionId ?: "Unknown" + + val connectionState = when (connection) { + is ActiveConnection.NotConnected -> "NotConnected" + is ActiveConnection.ValidIam -> "Connected" + is ActiveConnection.ExpiredIam -> "Expired" + else -> "Unknown" + } + return FeatureAuthDetails( + authType, + authRegion, + connectionState + ) +} + +fun getConnectionDetailsForFeature(project: Project, featureId: BearerTokenFeatureSet): FeatureAuthDetails? { + val connection = checkBearerConnectionValidity(project, featureId) + if (connection.activeConnectionBearer == null) return null + val authType = when (connection.connectionType) { + ActiveConnectionType.BUILDER_ID -> "BuilderId" + ActiveConnectionType.IAM_IDC -> "Idc" + else -> "Unknown" + } + val authRegion = connection.activeConnectionBearer?.region ?: "Unknown" + + val connectionState = when (connection) { + is ActiveConnection.NotConnected -> "NotConnected" + is ActiveConnection.ValidBearer -> "Connected" + is ActiveConnection.ExpiredBearer -> "Expired" + else -> "Unknown" + } + return FeatureAuthDetails( + authType, + authRegion, + connectionState + ) +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/plugin/PluginUpdateManager.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/plugin/PluginUpdateManager.kt index 80dea49e17c..384c38bf1e6 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/plugin/PluginUpdateManager.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/plugin/PluginUpdateManager.kt @@ -200,7 +200,7 @@ class PluginUpdateManager : Disposable { // TODO: Optimize this to only search the result for AWS plugins fun getUpdateInfo(): Collection = UpdateChecker.getPluginUpdates() ?: emptyList() - internal fun updatePlugin(pluginDescriptor: IdeaPluginDescriptor, progressIndicator: ProgressIndicator): Boolean { + fun updatePlugin(pluginDescriptor: IdeaPluginDescriptor, progressIndicator: ProgressIndicator): Boolean { val pluginName = pluginDescriptor.name // wasUpdatedWithRestart means that, it was an update and it needs to restart to apply diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt index 0bd81b96227..5040a199f55 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/utils/NotificationUtils.kt @@ -17,6 +17,8 @@ import com.intellij.ui.ScrollPaneFactory import org.slf4j.LoggerFactory import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.core.notifications.BannerNotificationService +import software.aws.toolkits.jetbrains.core.notifications.NotificationDismissalState import software.aws.toolkits.resources.AwsCoreBundle import javax.swing.JLabel import javax.swing.JTextArea @@ -48,6 +50,34 @@ private fun notify(type: NotificationType, title: String, content: String = "", notify(notification, project) } +fun notifyStickyWithData( + type: NotificationType, + title: String, + content: String = "", + project: Project? = null, + notificationActions: Collection, + id: String, +) { + val notification = Notification(GROUP_DISPLAY_ID_STICKY, title, content, type) + notificationActions.forEach { + notification.addAction(it) + } + + notification.addAction( + createNotificationExpiringAction( + object : AnAction("Dismiss") { + override fun actionPerformed(e: AnActionEvent) { + BannerNotificationService.getInstance().removeNotification(id) + NotificationDismissalState.getInstance().dismissNotification(id) + } + } + ) + + ) + + notify(notification, project) +} + private fun notifySticky(type: NotificationType, title: String, content: String = "", project: Project? = null, notificationActions: Collection) { val notification = Notification(GROUP_DISPLAY_ID_STICKY, title, content, type) notificationActions.forEach { diff --git a/plugins/core/jetbrains-community/tst-resources/exampleNotification2.json b/plugins/core/jetbrains-community/tst-resources/exampleNotification2.json new file mode 100644 index 00000000000..344f45191c7 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-resources/exampleNotification2.json @@ -0,0 +1,124 @@ +{ + "schema": { + "version": "2.0" +}, + "notifications": [ + { + + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "condition": { + "compute": { + "type": { + "or": [ + { + "==": "ec2" + }, + { + "==": "desktop" + } + ] + }, + "architecture": { + "!=": "x64" + } + }, + "os": { + "type": { + "anyOf": [ + "Darwin", + "Linux" + ] + }, + "version": { + "<=": "23.0.1.0" + } + + }, + "ide": { + "type": { + "noneOf": [ + "PyCharm", + "IDEA" + ] + }, + "version": { + "and": [ + { + ">=": "1.0" + }, + { + "<": "2.0" + } + ] + } + }, + "extensions": [ + { + "id": "aws.toolkit", + "version": { + "!=": "1.3334" + } + }, + { + "id": "amazon.q", + "version": { + "!=": "3.37.0" + } + } + ] + + , + "authx": [{ + "feature" : "q", + "type": { + "anyOf": [ + "IamIdentityCenter", + "AwsBuilderId" + ] + }, + "region": { + "==": "us-east-1" + }, + "connectionState": { + "!=": "Connected" + }, + "ssoScopes": { + "noneOf": [ + "codewhisperer:scope1", + "sso:account:access" + ] + } + } ] + }, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + }, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + }, + { + "type": "ShowUrl", + "content": { + "en-US": { + "title": "Click me!", + "url": "http://nowhere" + } + } + } + ] + } + ] +} diff --git a/plugins/core/jetbrains-community/tst-resources/olderNotification.json b/plugins/core/jetbrains-community/tst-resources/olderNotification.json new file mode 100644 index 00000000000..9ceee415d37 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-resources/olderNotification.json @@ -0,0 +1,115 @@ +{ + "schema": { + "version": "2.0" + }, + "notifications": [ + { + + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "condition": { + "compute": { + "type": { + "or": [ + { + "==": "ec2" + }, + { + "==": "desktop" + } + ] + }, + "architecture": { + "!=": "x64" + } + }, + "os": { + "type": { + "anyOf": [ + "Darwin", + "Linux" + ] + }, + "version": { + "<=": "23.0.1.0" + } + + }, + "ide": { + "type": { + "noneOf": [ + "PyCharm", + "IDEA" + ] + }, + "version": { + "and": [ + { + ">=": "1.0" + }, + { + "<": "2.0" + } + ] + } + }, + "extension": { + "type": { + "==": "AWS Toolkit for JetBrains" + }, + "version": { + "<": "1.47.0.0" + } + }, + "authx": { + "type": { + "anyOf": [ + "IamIdentityCenter", + "AwsBuilderId" + ] + }, + "region": { + "==": "us-east-1" + }, + "connectionState": { + "!=": "Connected" + }, + "ssoScopes": { + "noneOf": [ + "codewhisperer:scope1", + "sso:account:access" + ] + } + } + }, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + }, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + }, + { + "type": "ShowUrl", + "content": { + "en-US": { + "title": "Click me!", + "url": "http://nowhere" + } + } + } + ] + } + ] +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTest.kt new file mode 100644 index 00000000000..35311f21f4f --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTest.kt @@ -0,0 +1,136 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.testFramework.ApplicationExtension +import com.intellij.testFramework.ProjectRule +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.inputStream +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import java.io.InputStream +import java.nio.file.Paths +import java.util.stream.Stream + +@ExtendWith(ApplicationExtension::class) +class NotificationFormatUtilsTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + private lateinit var mockSystemDetails: SystemDetails + private lateinit var exampleNotification: InputStream + + @BeforeEach + fun setUp() { + mockSystemDetails = SystemDetails( + computeType = "Local", + computeArchitecture = "x86_64", + osType = "Linux", + osVersion = "5.4.0", + ideType = "IC", + ideVersion = "2023.1", + pluginVersions = mapOf( + "aws.toolkit" to "1.0", + "amazon.q" to "2.0" + ) + ) + + exampleNotification = javaClass.getResource("/exampleNotification2.json")?.let { + Paths.get(it.toURI()).takeIf { f -> f.exists() } + }?.inputStream() ?: throw RuntimeException("Test not found") + + mockkStatic("software.aws.toolkits.jetbrains.core.notifications.RulesEngineKt") + every { getCurrentSystemAndConnectionDetails() } returns mockSystemDetails + every { getConnectionDetailsForFeature(projectRule.project, BearerTokenFeatureSet.Q) } returns FeatureAuthDetails( + "Idc", + "us-west-2", + "Connected" + ) + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun `test System Details`() { + val result = getCurrentSystemAndConnectionDetails() + assertThat(mockSystemDetails).isEqualTo(result) + } + + @Test + fun `check Json Validity which has all the required fields`() { + assertDoesNotThrow { + mapper.readValue(exampleNotification) + } + } + + @Test + fun `No schema version associated with the notification file throws an exception`() { + assertThrows { + mapper.readValue(exampleNotificationWithoutSchema) + } + } + + @Test + fun `No notifications present with the version file does not throw an exception`() { + assertDoesNotThrow { + mapper.readValue(exampleNotificationWithoutNotification) + } + } + + @ParameterizedTest + @MethodSource("validNotifications") + fun `The notification is shown`(notification: String, expectedData: NotificationData) { + val notificationData = mapper.readValue(notification) + assertThat(notificationData).isEqualTo(expectedData) + val shouldShow = RulesEngine.displayNotification(projectRule.project, notificationData) + assertThat(shouldShow).isTrue + } + + @ParameterizedTest + @MethodSource("invalidNotifications") + fun `The notification is not shown`(notification: String, expectedData: NotificationData) { + val notificationData = mapper.readValue(notification) + assertThat(notificationData).isEqualTo(expectedData) + val shouldShow = RulesEngine.displayNotification(projectRule.project, notificationData) + assertThat(shouldShow).isFalse + } + + companion object { + @JvmStatic + fun validNotifications(): Stream = Stream.of( + Arguments.of(notificationWithConditionsOrActions, notificationWithConditionsOrActionsData), + Arguments.of(notificationWithoutConditionsOrActions, notificationsWithoutConditionsOrActionsData), + Arguments.of(notificationWithValidConnection, notificationWithValidConnectionData) + ) + + @JvmStatic + fun invalidNotifications(): Stream = Stream.of( + Arguments.of(validComputeInvalidOs, validOsInvalidComputeData), + Arguments.of(invalidExtensionVersion, invalidExtensionVersionData), + Arguments.of(invalidIdeTypeAndVersion, invalidIdeTypeAndVersionData) + ) + + private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTestCases.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTestCases.kt new file mode 100644 index 00000000000..9a39843b3f6 --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTestCases.kt @@ -0,0 +1,362 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +val validComputeInvalidOs = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "compute": { + "type": {"==": "Local"} +}, +"os": { + "type": {"==": "Windows"} +} +}, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + } + ], + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val validOsInvalidComputeData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = ComputeType(type = NotificationExpression.ComparisonCondition("Local"), architecture = null), + os = SystemType(type = NotificationExpression.ComparisonCondition("Windows"), version = null), + ide = null, + extension = null, + authx = null + ), + actions = listOf( + NotificationFollowupActions( + type = "ShowMarketplace", + content = NotificationFollowupActionsContent( + NotificationActionDescription( + title = "Go to market", + url = null + ) + ) + ) + ), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val invalidExtensionVersion = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "extension": [ + { + "id": "aws.toolkit", + "version": { + "!=": "1.3334" + } + }, + { + "id": "amazon.q", + "version": { + ">": "3.37.0" + } + } + ] +}, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val invalidExtensionVersionData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = null, + os = null, + ide = null, + extension = listOf( + ExtensionType( + id = "aws.toolkit", + version = NotificationExpression.NotEqualsCondition("1.3334") + ), + ExtensionType( + id = "amazon.q", + version = NotificationExpression.GreaterThanCondition("3.37.0") + ) + ), + authx = null + + ), + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val exampleNotificationWithoutSchema = """ + { + "notifications": [ + { + "id": "notification-001", + "title": "Test Notification", + "description": "This is a test notification", + "type": "INFO", + + "rules": { + "computeType": "Local", + "osType": "Linux", + "ideType": "IC", + "pluginVersion": { + "aws.toolkit": "1.0" + } + } + } + ] + } +""".trimIndent() + +val exampleNotificationWithoutNotification = """ + { + "schema": { + "version": "2.0" +} + + } +""".trimIndent() + +val notificationWithoutConditionsOrActions = """ + { + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + } + } + + +""".trimIndent() + +val notificationsWithoutConditionsOrActionsData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = null, + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val notificationWithConditionsOrActions = """ + { + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "condition": { + "compute": { + "type": { + + "==": "Local" + + } + } + }, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + } + ], + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + } + } + +""".trimIndent() + +val notificationWithConditionsOrActionsData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = ComputeType(type = NotificationExpression.ComparisonCondition("Local"), architecture = null), + os = null, + ide = null, + extension = null, + authx = null + ), + actions = listOf( + NotificationFollowupActions( + type = "ShowMarketplace", + content = NotificationFollowupActionsContent( + NotificationActionDescription( + title = "Go to market", + url = null + ) + ) + ) + ), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val notificationWithValidConnection = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "authx": [{ + "feature" : "q", + "type": { + "anyOf": [ + "Idc", + "BuilderId" + ] + }, + "region": { + "==": "us-west-2" + }, + "connectionState": { + "==": "Connected" + } + } ] +}, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val notificationWithValidConnectionData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = null, + os = null, + ide = null, + extension = null, + authx = listOf( + AuthxType( + feature = "q", + type = NotificationExpression.AnyOfCondition(listOf("Idc", "BuilderId")), + region = NotificationExpression.ComparisonCondition("us-west-2"), + connectionState = NotificationExpression.ComparisonCondition("Connected"), + ssoScopes = null + ) + ) + ), + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val invalidIdeTypeAndVersion = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "ide": { + "type": {"noneOf": ["IC","IU","RD"]}, + "version": {"!=": "1.3334"} +} +}, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val invalidIdeTypeAndVersionData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = null, + os = null, + ide = SystemType( + type = NotificationExpression.NoneOfCondition(listOf("IC", "IU", "RD")), + version = NotificationExpression.NotEqualsCondition("1.3334") + ), + extension = null, + authx = null + + ), + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationManagerTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationManagerTest.kt new file mode 100644 index 00000000000..3e112a23055 --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationManagerTest.kt @@ -0,0 +1,45 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.testFramework.ApplicationExtension +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extension +import org.junit.jupiter.api.extension.RegisterExtension + +@ExtendWith(ApplicationExtension::class) +class NotificationManagerTest { + + val projectRule = ProjectRule() + + @JvmField + @RegisterExtension + val testExtension = object : Extension { + fun getProject() = projectRule.project + } + + @Test + fun `If no follow-up actions, expand action is present`() { + val sut = NotificationManager.createActions(projectRule.project, listOf(), "Dummy Test Action", "Dummy title") + assertThat(sut).isNotNull + assertThat(sut).hasSize(1) + assertThat(sut.first().title).isEqualTo("More...") + } + + @Test + fun `Show Url action shows the option to learn more`() { + val followupActions = NotificationFollowupActions( + "UpdateExtension", + NotificationFollowupActionsContent(NotificationActionDescription("title", null)) + ) + val sut = NotificationManager.createActions(projectRule.project, listOf(followupActions), "Dummy Test Action", "Dummy title") + assertThat(sut).isNotNull + assertThat(sut).hasSize(2) + assertThat(sut.first().title).isEqualTo("Update") + assertThat(sut[1].title).isEqualTo("More...") + } +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt new file mode 100644 index 00000000000..3e16c4dd28e --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationPollingServiceTest.kt @@ -0,0 +1,111 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.testFramework.ApplicationExtension +import com.intellij.util.io.HttpRequests +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import software.aws.toolkits.core.utils.RemoteResourceResolver +import software.aws.toolkits.jetbrains.core.RemoteResourceResolverProvider +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean + +@ExtendWith(ApplicationExtension::class) +class NotificationPollingServiceTest { + private lateinit var sut: NotificationPollingService + private lateinit var mockResolver: RemoteResourceResolver + private lateinit var mockProvider: RemoteResourceResolverProvider + private lateinit var observer: () -> Unit + private val testPath = Path.of("/test/path") + + @BeforeEach + fun setUp() { + sut = NotificationPollingService() + + mockResolver = mockk { + every { resolve(any()) } returns CompletableFuture.completedFuture(testPath) + } + + mockProvider = mockk { + every { get() } returns mockResolver + } + + val providerField = NotificationPollingService::class.java + .getDeclaredField("resourceResolver") + providerField.isAccessible = true + providerField.set(sut, mockProvider) + + // Create mock observers + observer = mockk<() -> Unit>() + every { observer.invoke() } just Runs + + val observersField = NotificationPollingService::class.java + .getDeclaredField("observers") + .apply { isAccessible = true } + + observersField.set(sut, mutableListOf(observer)) + } + + @AfterEach + fun tearDown() { + sut.dispose() + } + + @Test + fun `test pollForNotifications when ETag matches - no new notifications`() { + NotificationEtagState.getInstance().etag = "same" + val firstPollField = NotificationPollingService::class.java + .getDeclaredField("isFirstPoll") + .apply { isAccessible = true } + firstPollField.set(sut, AtomicBoolean(false)) + + mockkStatic(HttpRequests::class) { + every { + HttpRequests.request(any()) + .userAgent(any()) + .connect(any()) + } returns "same" + sut.startPolling() + } + verify(exactly = 0) { observer.invoke() } + } + + @Test + fun `test pollForNotifications when ETag matches on startup - notify observers`() { + NotificationEtagState.getInstance().etag = "same" + mockkStatic(HttpRequests::class) { + every { + HttpRequests.request(any()) + .userAgent(any()) + .connect(any()) + } returns "same" + sut.startPolling() + } + verify(exactly = 1) { observer.invoke() } + } + + @Test + fun `test pollForNotifications when ETag different - notify observers`() { + NotificationEtagState.getInstance().etag = "oldETag" + mockkStatic(HttpRequests::class) { + every { + HttpRequests.request(any()) + .userAgent(any()) + .connect(any()) + } returns "newEtag" + sut.startPolling() + } + verify(exactly = 1) { observer.invoke() } + } +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt new file mode 100644 index 00000000000..01b77f2234a --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBaseTest.kt @@ -0,0 +1,161 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.concurrent.atomic.AtomicBoolean + +@ExtendWith(ApplicationExtension::class) +class ProcessNotificationsBaseTest { + private lateinit var sut: ProcessNotificationsBase + private lateinit var project: Project + private lateinit var dismissalState: NotificationDismissalState + + @BeforeEach + fun setUp() { + project = mockk() + dismissalState = spyk(NotificationDismissalState()) + + mockkObject(NotificationDismissalState) + every { NotificationDismissalState.getInstance() } returns dismissalState + + sut = spyk( + objToCopy = ProcessNotificationsBase(project) + ) + } + + @Test + fun `startup notifications are only processed on first poll`() { + resetIsStartup() + val startupNotification = createNotification("startup-1", NotificationScheduleType.STARTUP) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(startupNotification) + every { dismissalState.isDismissed(any()) } returns false + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + + // Second poll + sut.retrieveStartupAndEmergencyNotifications() + + // Verify processNotification wasn't called again + verify(exactly = 1) { sut.processNotification(project, any()) } + } + + @Test + fun `non startup notifications are processed on every poll`() { + val emergencyNotification = createNotification("emergency-1", NotificationScheduleType.EMERGENCY) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(emergencyNotification) + every { dismissalState.isDismissed(any()) } returns false + + // First poll + sut.retrieveStartupAndEmergencyNotifications() + // Second poll + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 2) { sut.processNotification(project, emergencyNotification) } + } + + @Test + fun `dismissed notifications are not processed`() { + val notification = createNotification("toBeDismissed-1", NotificationScheduleType.EMERGENCY) + every { sut["getNotificationsFromFile"]() } returns createNotificationsList(notification) + + // first poll results in showing/dismissal + sut.retrieveStartupAndEmergencyNotifications() + NotificationDismissalState.getInstance().dismissNotification(notification.id) + + // second poll skips processing + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, any()) } + } + + @Test + fun `null notifications list is handled gracefully`() { + every { sut["getNotificationsFromFile"]() } returns null + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 0) { sut.processNotification(project, any()) } + } + + @Test + fun `empty notifications list is handled gracefully`() { + every { sut["getNotificationsFromFile"]() } returns createNotificationsList() + + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 0) { sut.processNotification(project, any()) } + } + + @Test + fun `multiple notifications are processed correctly`() { + val startupNotification = createNotification("startup-1", NotificationScheduleType.STARTUP) + val emergencyNotification = createNotification("emergency-1", NotificationScheduleType.EMERGENCY) + + every { sut["getNotificationsFromFile"]() } returns createNotificationsList( + startupNotification, + emergencyNotification + ) + every { dismissalState.isDismissed(any()) } returns false + + // First poll - both should be processed + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + verify(exactly = 1) { sut.processNotification(project, emergencyNotification) } + + // Second poll - only emergency should be processed + sut.retrieveStartupAndEmergencyNotifications() + + verify(exactly = 1) { sut.processNotification(project, startupNotification) } + verify(exactly = 2) { sut.processNotification(project, emergencyNotification) } + } + + // Helper functions to create test data + private fun createNotification(id: String, type: NotificationScheduleType) = NotificationData( + id = id, + schedule = NotificationSchedule(type = type), + severity = "INFO", + condition = null, + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ), + actions = emptyList() + ) + + private fun createNotificationsList(vararg notifications: NotificationData) = NotificationsList( + schema = Schema("1.0"), + notifications = notifications.toList() + ) + + private fun resetIsStartup() { + val clazz = Class.forName("software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBaseKt") + val field = clazz.getDeclaredField("isStartup") + field.isAccessible = true + + val value = field.get(null) as AtomicBoolean + value.set(true) + } + + @AfterEach + fun tearDown() { + unmockkAll() + } +} diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index 8f89bda7223..f64ba2c2693 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -1257,6 +1257,7 @@ gateway.connection.workflow.start_ide=Start IDE gateway.connection.workflow.step_failed=\nStep failed exceptionally\n gateway.connection.workflow.step_skipped=Step skipped gateway.connection.workflow.step_successful=\nStep completed successfully\n +general.acknowledge=Acknowledge general.add.another=Add another general.auth.reauthenticate=Reauthenticate general.cancel=Cancel @@ -1271,6 +1272,7 @@ general.default=Default general.delete=Delete general.delete_accessible_name=Delete confirmation box general.details=(details) +general.dismiss=Dismiss general.execute_button=Execute general.execution.canceled=canceled general.execution.cli_error=Command did not exit successfully, exit code: {0}\n @@ -1284,10 +1286,12 @@ general.in_progress_button=In progress general.logs=Logs general.message=Message general.more=More +general.more_dialog=More... general.name.label=Name: general.no_changes=No changes were provided general.notification.action.hide_forever=Don't show again general.notification.action.hide_once=Dismiss +general.ok=OK general.open.in.progress=Opening... general.open_in_aws_console=Open in AWS Console general.open_in_aws_console.error=Failed to open link in browser @@ -1530,6 +1534,10 @@ lambda.workflow.update_code.wait_for_updatable=Waiting for function to transitio loading_resource.failed=Failed loading resources loading_resource.loading=Loading... loading_resource.still_loading=Resources are still loading +notification.changelog=Changelog +notification.expand=Expand +notification.learn_more=Learn more +notification.update=Update plugin.incompatible.fix=Disable incompatible plugins and restart IDE plugin.incompatible.message=The plugin versions for Amazon Q, AWS Toolkit, and AWS Toolkit Core must match or conflicts may occur. plugin.incompatible.title=AWS Plugin Incompatibility diff --git a/plugins/core/src/main/resources/META-INF/plugin.xml b/plugins/core/src/main/resources/META-INF/plugin.xml index dbc7e6b3972..9703141c302 100644 --- a/plugins/core/src/main/resources/META-INF/plugin.xml +++ b/plugins/core/src/main/resources/META-INF/plugin.xml @@ -22,8 +22,11 @@ + + + diff --git a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml index 5172c1fc686..969365d6d9c 100644 --- a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml +++ b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml @@ -196,7 +196,6 @@ - diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt index 0f05448be91..1f67c8c2fb0 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/ExplorerNewConnectionAction.kt @@ -7,7 +7,7 @@ import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.DumbAwareAction -import software.aws.toolkits.jetbrains.core.explorer.showWebview +import software.aws.toolkits.jetbrains.core.explorer.ShowToolkitListener import software.aws.toolkits.jetbrains.core.explorer.webview.ToolkitWebviewPanel import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel import software.aws.toolkits.jetbrains.core.webview.BrowserState @@ -25,7 +25,7 @@ class ExplorerNewConnectionAction : DumbAwareAction(AllIcons.General.Add) { GettingStartedPanel.openPanel(it) } else { ToolkitWebviewPanel.getInstance(it).browser?.prepareBrowser(BrowserState(FeatureId.AwsExplorer, true)) - showWebview(it) + ShowToolkitListener.showWebview(it) } UiTelemetry.click(it, "devtools_connectToAws") } diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt index 67be9bcf010..b0a1c8601d7 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/actions/NewConnectionAction.kt @@ -6,7 +6,7 @@ package software.aws.toolkits.jetbrains.core.credentials.actions import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.DumbAwareAction -import software.aws.toolkits.jetbrains.core.explorer.showWebview +import software.aws.toolkits.jetbrains.core.explorer.ShowToolkitListener import software.aws.toolkits.jetbrains.core.explorer.webview.ToolkitWebviewPanel import software.aws.toolkits.jetbrains.core.gettingstarted.editor.GettingStartedPanel import software.aws.toolkits.jetbrains.core.webview.BrowserState @@ -22,7 +22,7 @@ class NewConnectionAction : DumbAwareAction() { GettingStartedPanel.openPanel(it, connectionInitiatedFromExplorer = true) } else { ToolkitWebviewPanel.getInstance(it).browser?.prepareBrowser(BrowserState(FeatureId.AwsExplorer, true)) - showWebview(it) + ShowToolkitListener.showWebview(it) } UiTelemetry.click(e.project, "auth_gettingstarted_explorermenu") } diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt index 0174aef2e08..6179672b8c8 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerFactory.kt @@ -6,12 +6,16 @@ package software.aws.toolkits.jetbrains.core.explorer import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.runInEdt import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ex.ToolWindowEx +import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.messages.Topic +import com.intellij.util.ui.components.BorderLayoutPanel import software.aws.toolkits.core.utils.debug import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.AwsToolkit @@ -32,15 +36,30 @@ import software.aws.toolkits.jetbrains.core.experiments.ExperimentsActionGroup import software.aws.toolkits.jetbrains.core.explorer.webview.ToolkitWebviewPanel import software.aws.toolkits.jetbrains.core.explorer.webview.shouldPromptToolkitReauth import software.aws.toolkits.jetbrains.core.help.HelpIds +import software.aws.toolkits.jetbrains.core.notifications.NotificationPanel +import software.aws.toolkits.jetbrains.core.notifications.ProcessNotificationsBase import software.aws.toolkits.jetbrains.core.webview.BrowserState import software.aws.toolkits.jetbrains.utils.actions.OpenBrowserAction import software.aws.toolkits.jetbrains.utils.isTookitConnected import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.FeatureId +import java.util.EventListener import javax.swing.JComponent class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val notificationPanel = NotificationPanel() + val toolkitPanel = Wrapper() + val mainPanel = BorderLayoutPanel() + mainPanel.addToTop(notificationPanel) + mainPanel.add(toolkitPanel) + val notifListener = ProcessNotificationsBase.getInstance(project) + notifListener.addListenerForNotification { bannerContent -> + runInEdt { + notificationPanel.updateNotificationPanel(bannerContent) + } + } toolWindow.helpId = HelpIds.EXPLORER_WINDOW.id if (toolWindow is ToolWindowEx) { @@ -83,7 +102,9 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { AwsToolkitExplorerToolWindow.getInstance(project) } - val content = contentManager.factory.createContent(component, null, false).also { + toolkitPanel.setContent(component) + + val content = contentManager.factory.createContent(mainPanel, null, false).also { it.isCloseable = true it.isPinnable = true } @@ -95,7 +116,7 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { ToolkitConnectionManagerListener.TOPIC, object : ToolkitConnectionManagerListener { override fun activeConnectionChanged(newConnection: ToolkitConnection?) { - connectionChanged(project, newConnection) + connectionChanged(project, newConnection, toolkitPanel) } } ) @@ -104,7 +125,7 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, object : ConnectionSettingsStateChangeNotifier { override fun settingsStateChanged(newState: ConnectionState) { - settingsStateChanged(project, newState) + settingsStateChanged(project, newState, toolkitPanel) } } ) @@ -116,18 +137,31 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { if (ToolkitConnectionManager.getInstance(project) .connectionStateForFeature(CodeCatalystConnection.getInstance()) == BearerTokenAuthState.AUTHORIZED ) { - showExplorerTree(project) + loadContent(AwsToolkitExplorerToolWindow.getInstance(project), toolkitPanel) } } } ) + + project.messageBus.connect().subscribe( + ShowToolkitListener.TOPIC, + object : ShowToolkitListener { + override fun showWebview(project: Project) { + loadContent(ToolkitWebviewPanel.getInstance(project).component, toolkitPanel) + } + + override fun showExplorerTree(project: Project) { + loadContent(AwsToolkitExplorerToolWindow.getInstance(project), toolkitPanel) + } + } + ) } override fun init(toolWindow: ToolWindow) { toolWindow.stripeTitle = message("aws.notification.title") } - private fun connectionChanged(project: Project, newConnection: ToolkitConnection?) { + private fun connectionChanged(project: Project, newConnection: ToolkitConnection?, toolkitPanel: Wrapper) { val isNewConnToolkitConnection = when (newConnection) { is AwsConnectionManagerConnection -> { LOG.debug { "IAM connection" } @@ -148,16 +182,16 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { } if (isNewConnToolkitConnection) { - showExplorerTree(project) + loadContent(AwsToolkitExplorerToolWindow.getInstance(project), toolkitPanel) } else if (!isTookitConnected(project) || shouldPromptToolkitReauth(project)) { ToolkitWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AwsExplorer)) - showWebview(project) + loadContent(ToolkitWebviewPanel.getInstance(project).component, toolkitPanel) } else { - showExplorerTree(project) + loadContent(AwsToolkitExplorerToolWindow.getInstance(project), toolkitPanel) } } - private fun settingsStateChanged(project: Project, newState: ConnectionState) { + private fun settingsStateChanged(project: Project, newState: ConnectionState, toolkitPanel: Wrapper) { val isToolkitConnected = if (newState is ConnectionState.ValidConnection) { true } else { @@ -168,9 +202,15 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { if (!isToolkitConnected || shouldPromptToolkitReauth(project)) { ToolkitWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AwsExplorer)) - showWebview(project) + loadContent(ToolkitWebviewPanel.getInstance(project).component, toolkitPanel) } else { - showExplorerTree(project) + loadContent(AwsToolkitExplorerToolWindow.getInstance(project), toolkitPanel) + } + } + + private fun loadContent(component: JComponent, toolkitPanel: Wrapper) { + runInEdt { + toolkitPanel.setContent(component) } } @@ -180,22 +220,20 @@ class AwsToolkitExplorerFactory : ToolWindowFactory, DumbAware { } } -fun showWebview(project: Project) { - AwsToolkitExplorerToolWindow.toolWindow(project).loadContent(ToolkitWebviewPanel.getInstance(project).component) -} +interface ShowToolkitListener : EventListener { + fun showExplorerTree(project: Project) + fun showWebview(project: Project) -fun showExplorerTree(project: Project) { - AwsToolkitExplorerToolWindow.toolWindow(project).loadContent(AwsToolkitExplorerToolWindow.getInstance(project)) -} + companion object { + @Topic.AppLevel + val TOPIC = Topic.create("Show Explorer contents", ShowToolkitListener::class.java) -private fun ToolWindow.loadContent(component: JComponent) { - val content = contentManager.factory.createContent(component, null, false).also { - it.isCloseable = true - it.isPinnable = true - } + fun showExplorerTree(project: Project) { + ApplicationManager.getApplication().messageBus.syncPublisher(TOPIC).showExplorerTree(project) + } - runInEdt { - contentManager.removeAllContents(true) - contentManager.addContent(content) + fun showWebview(project: Project) { + ApplicationManager.getApplication().messageBus.syncPublisher(TOPIC).showWebview(project) + } } } diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt index a3f3f3a4562..c670548e7c1 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ExplorerToolWindow.kt @@ -211,7 +211,7 @@ class ExplorerToolWindow(private val project: Project) : GettingStartedPanel.openPanel(project) } else { ToolkitWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AwsExplorer, true)) - showWebview(project) + ShowToolkitListener.showWebview(project) } } } diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/OuterToolkitPanel.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/OuterToolkitPanel.kt new file mode 100644 index 00000000000..82717b337dc --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/OuterToolkitPanel.kt @@ -0,0 +1,36 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer.webview + +import com.intellij.openapi.project.Project +import com.intellij.ui.components.panels.Wrapper +import com.intellij.util.ui.components.BorderLayoutPanel +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.explorer.AwsToolkitExplorerToolWindow +import software.aws.toolkits.jetbrains.utils.isTookitConnected +import javax.swing.JComponent + +class OuterToolkitPanel(val project: Project) : BorderLayoutPanel() { + private val wrapper = Wrapper() + init { + isOpaque = false + addToCenter(wrapper) + val component = if (!isTookitConnected(project) || shouldPromptToolkitReauth(project)) { + ToolkitWebviewPanel.getInstance(project).component + } else { + AwsToolkitExplorerToolWindow.getInstance(project) + } + + updateToolkitPanel(component) + } + + fun updateToolkitPanel(content: JComponent) { + try { + wrapper.setContent(content) + } catch (e: Exception) { + getLogger().error { "Error while creating window" } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/ToolkitLoginWebview.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/ToolkitLoginWebview.kt index 481e84e7f9d..8ea17eceaa0 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/ToolkitLoginWebview.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/webview/ToolkitLoginWebview.kt @@ -46,7 +46,7 @@ import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeCatalystConn import software.aws.toolkits.jetbrains.core.credentials.sono.CODECATALYST_SCOPES import software.aws.toolkits.jetbrains.core.credentials.sono.IDENTITY_CENTER_ROLE_ACCESS_SCOPE import software.aws.toolkits.jetbrains.core.credentials.sono.isSono -import software.aws.toolkits.jetbrains.core.explorer.showExplorerTree +import software.aws.toolkits.jetbrains.core.explorer.ShowToolkitListener import software.aws.toolkits.jetbrains.core.gettingstarted.IdcRolePopup import software.aws.toolkits.jetbrains.core.gettingstarted.IdcRolePopupState import software.aws.toolkits.jetbrains.core.region.AwsRegionProvider @@ -211,7 +211,7 @@ class ToolkitWebviewBrowser(val project: Project, private val parentDisposable: } is BrowserMessage.ToggleBrowser -> { - showExplorerTree(project) + ShowToolkitListener.showExplorerTree(project) } is BrowserMessage.CancelLogin -> { diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/ToolkitGettingStartedAuthUtils.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/ToolkitGettingStartedAuthUtils.kt index 98da68929aa..53b5479b47f 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/ToolkitGettingStartedAuthUtils.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/gettingstarted/ToolkitGettingStartedAuthUtils.kt @@ -6,7 +6,7 @@ package software.aws.toolkits.jetbrains.core.gettingstarted import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.project.Project import software.aws.toolkits.jetbrains.core.credentials.sono.CODECATALYST_SCOPES -import software.aws.toolkits.jetbrains.core.explorer.showWebview +import software.aws.toolkits.jetbrains.core.explorer.ShowToolkitListener import software.aws.toolkits.jetbrains.core.explorer.webview.ToolkitWebviewPanel import software.aws.toolkits.jetbrains.core.gettingstarted.editor.SourceOfEntry import software.aws.toolkits.jetbrains.core.gettingstarted.editor.getConnectionCount @@ -32,7 +32,7 @@ fun requestCredentialsForCodeCatalyst( ): Boolean? { if (isQWebviewsAvailable() && project != null) { ToolkitWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.Codecatalyst, true)) // TODO: consume data - showWebview(project) + ShowToolkitListener.showWebview(project) return null } @@ -128,7 +128,7 @@ fun requestCredentialsForExplorer( ): Boolean? { if (isQWebviewsAvailable()) { ToolkitWebviewPanel.getInstance(project).browser?.prepareBrowser(BrowserState(FeatureId.AwsExplorer, true)) // TODO: consume data - showWebview(project) + ShowToolkitListener.showWebview(project) return null }