From feadd1bfce23b17ea670c27e66bb856aeb865289 Mon Sep 17 00:00:00 2001 From: manodnyab <66754471+manodnyab@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:42:09 -0800 Subject: [PATCH 01/44] build: update jb 253 to stable (#6169) * build: update jb 253 to stable * remove non-required versions * resolve detekt failures --- .../toolkits/gradle/intellij/IdeVersions.kt | 20 ++++++++-------- ...oolkit-publish-root-conventions.gradle.kts | 24 +++++++++++++++---- .../chat/jetbrains-community/build.gradle.kts | 7 ++++++ .../jetbrains-community/build.gradle.kts | 7 ++++++ .../jetbrains-community/build.gradle.kts | 7 ++++++ .../jetbrains-community/build.gradle.kts | 7 ++++++ .../core/jetbrains-community/build.gradle.kts | 10 ++++++++ ui-tests-starter/build.gradle.kts | 7 +++++- 8 files changed, 74 insertions(+), 15 deletions(-) diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt index 84d33ba8aae..4ed6d061bfa 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt @@ -153,11 +153,11 @@ object IdeVersions { Profile( name = "2025.3", gateway = ProductProfile( - sdkVersion = "253.28086.53", + sdkVersion = "2025.3", bundledPlugins = listOf("org.jetbrains.plugins.terminal") ), community = ProductProfile( - sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + sdkVersion = "2025.3", bundledPlugins = commonPlugins + listOf( "com.intellij.java", "com.intellij.gradle", @@ -165,14 +165,14 @@ object IdeVersions { "com.intellij.properties" ), marketplacePlugins = listOf( - "org.toml.lang:253.28294.86", - "PythonCore:253.28294.51", - "Docker:253.28294.90", - "com.intellij.modules.json:253.28294.51" + "org.toml.lang:253.28294.334", + "PythonCore:253.29346.138", + "Docker:253.29346.125", + "com.intellij.modules.json:253.28294.251" ) ), ultimate = ProductProfile( - sdkVersion = "253.28294-EAP-CANDIDATE-SNAPSHOT", + sdkVersion = "2025.3", bundledPlugins = commonPlugins + listOf( "JavaScript", "JavaScriptDebugger", @@ -180,9 +180,9 @@ object IdeVersions { "com.jetbrains.codeWithMe" ), marketplacePlugins = listOf( - "Pythonid:253.28294.51", - "org.jetbrains.plugins.go:253.28294.51", - "com.intellij.modules.json:253.28294.51" + "Pythonid:253.29346.138", + "org.jetbrains.plugins.go:253.29346.50", + "com.intellij.modules.json:253.28294.251" ) ), rider = RiderProfile( diff --git a/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts b/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts index d7b079b1160..5a609c32d9a 100644 --- a/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts @@ -24,8 +24,14 @@ intellijPlatform { pluginVerification { ides { // recommended() appears to resolve latest EAP for a product? - ide(provider { IntelliJPlatformType.IntellijIdeaCommunity }, toolkitIntelliJ.version()) - ide(provider { IntelliJPlatformType.IntellijIdeaUltimate }, toolkitIntelliJ.version()) + // Starting with 2025.3, IntelliJ IDEA is unified (no separate Community edition) + val version = toolkitIntelliJ.version().get() + if (version.startsWith("2025.3")) { + ide(provider { IntelliJPlatformType.IntellijIdeaUltimate }, toolkitIntelliJ.version()) + } else { + ide(provider { IntelliJPlatformType.IntellijIdeaCommunity }, toolkitIntelliJ.version()) + ide(provider { IntelliJPlatformType.IntellijIdeaUltimate }, toolkitIntelliJ.version()) + } } } } @@ -49,7 +55,12 @@ dependencies { // prefer versions declared in IdeVersions toolkitIntelliJ.apply { - ideFlavor.convention(IdeFlavor.values().firstOrNull { it.name == runIdeVariant.orNull } ?: IdeFlavor.IC) + val defaultFlavor = if (version().get().startsWith("2025.3")) { + IdeFlavor.IU // Use unified IntelliJ IDEA for 2025.3+ + } else { + IdeFlavor.IC // Use Community for older versions + } + ideFlavor.convention(IdeFlavor.values().firstOrNull { it.name == runIdeVariant.orNull } ?: defaultFlavor) } val (type, version) = if (runIdeVariant.isPresent) { val type = toolkitIntelliJ.ideFlavor.map { IntelliJPlatformType.fromCode(it.toString()) } @@ -57,7 +68,12 @@ dependencies { type to version } else { - provider { IntelliJPlatformType.IntellijIdeaCommunity } to toolkitIntelliJ.version() + val defaultType = if (toolkitIntelliJ.version().get().startsWith("2025.3")) { + provider { IntelliJPlatformType.IntellijIdeaUltimate } + } else { + provider { IntelliJPlatformType.IntellijIdeaCommunity } + } + defaultType to toolkitIntelliJ.version() } create(type, version, useInstaller = false) diff --git a/plugins/amazonq/chat/jetbrains-community/build.gradle.kts b/plugins/amazonq/chat/jetbrains-community/build.gradle.kts index 147d481b482..ef387294279 100644 --- a/plugins/amazonq/chat/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/chat/jetbrains-community/build.gradle.kts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -14,6 +15,12 @@ intellijToolkit { dependencies { intellijPlatform { localPlugin(project(":plugin-core")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } implementation(project(":plugin-amazonq:shared:jetbrains-community")) diff --git a/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts b/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts index cb64e7d4c9d..12751759f01 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/codetransform/jetbrains-community/build.gradle.kts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -14,6 +15,12 @@ intellijToolkit { dependencies { intellijPlatform { localPlugin(project(":plugin-core")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } implementation(project(":plugin-amazonq:shared:jetbrains-community")) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts index 824d0f016ff..ae6a6d60746 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/codewhisperer/jetbrains-community/build.gradle.kts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -14,6 +15,12 @@ intellijToolkit { dependencies { intellijPlatform { localPlugin(project(":plugin-core")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } compileOnly(project(":plugin-core:jetbrains-community")) diff --git a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts index 36313073d9b..efc0eb759ce 100644 --- a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts +++ b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.intellij.platform.gradle.models.Coordinates import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions plugins { id("toolkit-intellij-subplugin") @@ -16,6 +17,12 @@ dependencies { intellijPlatform { localPlugin(project(":plugin-core")) platformDependency(Coordinates(groupId = "com.jetbrains.intellij.rd", artifactId = "rd-platform")) + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } } compileOnlyApi(project(":plugin-core:jetbrains-community")) diff --git a/plugins/core/jetbrains-community/build.gradle.kts b/plugins/core/jetbrains-community/build.gradle.kts index 9a7fbdbc00e..dc9ce8cc69c 100644 --- a/plugins/core/jetbrains-community/build.gradle.kts +++ b/plugins/core/jetbrains-community/build.gradle.kts @@ -4,6 +4,7 @@ import io.gitlab.arturbosch.detekt.Detekt import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask import software.aws.toolkits.gradle.intellij.IdeFlavor +import software.aws.toolkits.gradle.intellij.IdeVersions import software.aws.toolkits.telemetry.generator.gradle.GenerateTelemetry plugins { @@ -79,6 +80,15 @@ dependencies { testImplementation(project(":plugin-core:core")) testRuntimeOnly(project(":plugin-core:sdk-codegen")) + + intellijPlatform { + // Required for collaboration auth credentials in 2025.3+ + val version = IdeVersions.ideProfile(project).ultimate.sdkVersion + if (version.startsWith("2025.3")) { + bundledModule("intellij.platform.collaborationTools.auth.base") + bundledModule("intellij.platform.collaborationTools.auth") + } + } } // fix implicit dependency on generated source diff --git a/ui-tests-starter/build.gradle.kts b/ui-tests-starter/build.gradle.kts index 5bd6f620958..7d7b6bf76a4 100644 --- a/ui-tests-starter/build.gradle.kts +++ b/ui-tests-starter/build.gradle.kts @@ -60,7 +60,12 @@ dependencies { intellijPlatform { val version = ideProfile.community.sdkVersion - intellijIdeaCommunity(version, !version.contains("SNAPSHOT")) + // Use unified IntelliJ IDEA for 2025.3+, Community for older versions + if (version.startsWith("2025.3")) { + intellijIdeaUltimate(version, !version.contains("SNAPSHOT")) + } else { + intellijIdeaCommunity(version, !version.contains("SNAPSHOT")) + } localPlugin(project(":plugin-core")) testImplementation(project(":plugin-core:core")) From 27e0ccd6295998e03f042694ea1e6ae6219dfd26 Mon Sep 17 00:00:00 2001 From: chungjac Date: Mon, 12 Jan 2026 12:59:01 -0800 Subject: [PATCH 02/44] fix(amazonq): enable external links to open in system browser (#6172) * fix: open links in browser for /help and chat responses Links in chat responses were not opening because: 1. Link URL was being read from wrong JSON path (node.link instead of node.params.link) 2. Was delegating to LSP server which has empty handlers Now opens browser directly on client side with correct URL extraction. * fix: use correct theme key for link color link.foreground returns black in some themes, use 'link' key instead which has the correct blue color * Detekt * fix: handle link clicks in chat and fix link color - Open browser directly when link click messages are received - Support both Flare (aws/chat/linkClick) and legacy (response-body-link-click) message formats - Fix link color by using correct theme key lookup - Add constants for legacy link click message types * Detekt * refactor: organize UI message constants properly - Move FOOTER_INFO_LINK_CLICK, RESPONSE_BODY_LINK_CLICK, SOURCE_LINK_CLICK from FlareChatCommands.kt to ChatConstants.kt - These constants don't follow aws/chat/ prefix pattern and belong with other chat constants - Update BrowserConnector.kt imports to use ChatConstants * refactor: organize UI message constants properly - Move FOOTER_INFO_LINK_CLICK, RESPONSE_BODY_LINK_CLICK, SOURCE_LINK_CLICK from FlareChatCommands.kt to ChatConstants.kt - These constants don't follow aws/chat/ prefix pattern and belong with other chat constants - Update BrowserConnector.kt imports to use ChatConstants * cleanup: remove build output files * revert: remove unintended package-lock.json changes * fix(rider): move off snapshot builds to stable 2025.3 release - Change Rider sdkVersion from '2025.3-SNAPSHOT' to '2025.3' - Resolves CI dependency resolution failures * Revert "fix(rider): move off snapshot builds to stable 2025.3 release" This reverts commit e8b12e6acb0690f986239847e12ea5b906dfffca. * ci: retry build * Remove unused legacy link click message types The legacy types (source-link-click, response-body-link-click, footer-info-link-click) are never hit - all link clicks go through the aws/chat/* LSP types. * Revert infoLinkClick and sourceLinkClick to use handleChat --------- Co-authored-by: Aseem Sharma Co-authored-by: Aseem sharma <198968351+aseemxs@users.noreply.github.com> --- .../jetbrains/services/amazonq/webview/BrowserConnector.kt | 2 +- .../services/amazonq/webview/theme/EditorThemeAdapter.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt index 44ffbca8a32..4b80f8aa208 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -390,7 +390,7 @@ class BrowserConnector( } CHAT_LINK_CLICK -> { - handleChat(AmazonQChatServer.linkClick, node) + node.get("params")?.get("link")?.asText()?.let { BrowserUtil.browse(it) } } CHAT_INFO_LINK_CLICK -> { diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt index a845e3c19f3..53874353ef2 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/theme/EditorThemeAdapter.kt @@ -91,7 +91,7 @@ class EditorThemeAdapter { defaultText = text, inactiveText = themeColor("TextField.inactiveForeground", default = 0x8C8C8C, darkDefault = 0x808080), - linkText = themeColor("link.foreground", "link", "Link.activeForeground", default = 0x589DF6), + linkText = themeColor("link", "Link.activeForeground", default = 0x589DF6), background = chatBackground, border = getBorderColor(currentScheme), From f155d01220ee8156af13ec3226421b3d9d7e93b1 Mon Sep 17 00:00:00 2001 From: manodnyab <66754471+manodnyab@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:06:13 -0800 Subject: [PATCH 03/44] fix(amazonq): fix ask a question not opening chat window (#6183) --- .../services/amazonq/toolwindow/AmazonQToolWindow.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt index 5df972bba09..ef82dec3b58 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQToolWindow.kt @@ -11,8 +11,6 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.ToolWindowManager import kotlinx.coroutines.CoroutineScope -import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteraction -import software.aws.toolkits.jetbrains.services.amazonq.onboarding.OnboardingPageInteractionType import software.aws.toolkits.jetbrains.services.amazonqCodeScan.runCodeScanMessage @Service(Service.Level.PROJECT) @@ -40,10 +38,6 @@ class AmazonQToolWindow private constructor( fun getStarted(project: Project) { // Make sure the window is shown showChatWindow(project) - - // Send the interaction message - val window = getInstance(project) - window.chatPanel.sendMessage(OnboardingPageInteraction(OnboardingPageInteractionType.CwcButtonClick), "cwc") } fun openScanTab(project: Project) { From 053535594617d4f93dcef6803f68717122f5486e Mon Sep 17 00:00:00 2001 From: manodnyab <66754471+manodnyab@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:39:57 -0800 Subject: [PATCH 04/44] fix(core): fix string comparison versions to semver (#6182) * fix(core): fix string comparison versions to semver * stop showing notifications in sandbox * detekt --- .../core/notifications/RulesEngine.kt | 68 ++++++++++----- .../core/notifications/RulesEngineTest.kt | 83 +++++++++++++++++++ 2 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt 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 index 4f5c174409e..a08245d00ba 100644 --- 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 @@ -8,6 +8,7 @@ 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 com.intellij.util.text.SemVer 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 @@ -48,7 +49,7 @@ object RulesEngine { 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 + val ideVersion = notificationIde.version?.let { evaluateNotificationExpression(it, actualIdeVersion, true) } ?: true return ide && ideVersion } @@ -57,12 +58,14 @@ object RulesEngine { val extensionsToBeChecked = notificationExtension.map { it.id } val pluginVersions = actualPluginVersions.filterKeys { extensionsToBeChecked.contains(it) } if (pluginVersions.isEmpty()) return false + // SNAPSHOT versions are development versions and should not receive notifications + if (pluginVersions.values.any { it.contains("SNAPSHOT", ignoreCase = true) }) return false return notificationExtension.all { extension -> val actualVersion = pluginVersions[extension.id] if (actualVersion == null) { true } else { - extension.version?.let { evaluateNotificationExpression(it, actualVersion) } ?: true + extension.version?.let { evaluateNotificationExpression(it, actualVersion, true) } ?: true } } } @@ -89,29 +92,52 @@ object RulesEngine { } } - 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 + fun evaluateNotificationExpression(notificationExpression: NotificationExpression, value: String, useSemverForComparison: Boolean = false): Boolean = + when (notificationExpression) { + is NotificationExpression.NotCondition -> performNotOp(notificationExpression, value, useSemverForComparison) + is NotificationExpression.OrCondition -> performOrOp(notificationExpression, value, useSemverForComparison) + is NotificationExpression.AndCondition -> performAndOp(notificationExpression, value, useSemverForComparison) + is NotificationExpression.ComparisonCondition -> notificationExpression.value == value + is NotificationExpression.NotEqualsCondition -> notificationExpression.value != value + is NotificationExpression.GreaterThanCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) > 0 + } else { + value > notificationExpression.value + } + is NotificationExpression.LessThanCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) < 0 + } else { + value < notificationExpression.value + } + is NotificationExpression.GreaterThanOrEqualsCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) >= 0 + } else { + value >= notificationExpression.value + } + is NotificationExpression.LessThanOrEqualsCondition -> if (useSemverForComparison) { + compareSemver(value, notificationExpression.value) <= 0 + } else { + value <= notificationExpression.value + } + is NotificationExpression.AnyOfCondition -> notificationExpression.value.contains(value) + is NotificationExpression.NoneOfCondition -> !notificationExpression.value.contains(value) + else -> true + } + + private fun compareSemver(actual: String, expected: String): Int { + val actualSemver = SemVer.parseFromText(actual) ?: return actual.compareTo(expected) + val expectedSemver = SemVer.parseFromText(expected) ?: return actual.compareTo(expected) + return actualSemver.compareTo(expectedSemver) } - private fun performNotOp(notificationOperation: NotificationExpression.NotCondition, actualValue: String): Boolean = - !evaluateNotificationExpression(notificationOperation.expectedValue, actualValue) + private fun performNotOp(notificationOperation: NotificationExpression.NotCondition, actualValue: String, useSemverForComparison: Boolean): Boolean = + !evaluateNotificationExpression(notificationOperation.expectedValue, actualValue, useSemverForComparison) - private fun performOrOp(notificationOperation: NotificationExpression.OrCondition, actualValue: String): Boolean = - notificationOperation.expectedValueList.any { evaluateNotificationExpression(it, actualValue) } + private fun performOrOp(notificationOperation: NotificationExpression.OrCondition, actualValue: String, useSemverForComparison: Boolean): Boolean = + notificationOperation.expectedValueList.any { evaluateNotificationExpression(it, actualValue, useSemverForComparison) } - private fun performAndOp(notificationOperation: NotificationExpression.AndCondition, actualValue: String): Boolean = - notificationOperation.expectedValueList.all { evaluateNotificationExpression(it, actualValue) } + private fun performAndOp(notificationOperation: NotificationExpression.AndCondition, actualValue: String, useSemverForComparison: Boolean): Boolean = + notificationOperation.expectedValueList.all { evaluateNotificationExpression(it, actualValue, useSemverForComparison) } } fun getCurrentSystemAndConnectionDetails(): SystemDetails { diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt new file mode 100644 index 00000000000..37f74bdd6c7 --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/RulesEngineTest.kt @@ -0,0 +1,83 @@ +// 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 org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class RulesEngineTest { + + @Test + fun `ComparisonCondition matches equal values`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.ComparisonCondition("test"), "test")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.ComparisonCondition("test"), "other")).isFalse() + } + + @Test + fun `NotEqualsCondition matches different values`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NotEqualsCondition("test"), "other")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NotEqualsCondition("test"), "test")).isFalse() + } + + @Test + fun `GreaterThanCondition compares strings lexicographically`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanCondition("2.0"), "3.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanCondition("2.0"), "1.0")).isFalse() + } + + @Test + fun `LessThanCondition compares strings lexicographically`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanCondition("3.74.0"), "3.101.0", true)).isFalse() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanCondition("2.0"), "3.0")).isFalse() + } + + @Test + fun `GreaterThanOrEqualsCondition includes equality`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanOrEqualsCondition("2.0"), "2.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.GreaterThanOrEqualsCondition("2.0"), "3.0")).isTrue() + } + + @Test + fun `LessThanOrEqualsCondition includes equality`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanOrEqualsCondition("2.0"), "2.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.LessThanOrEqualsCondition("2.0"), "1.0")).isTrue() + } + + @Test + fun `AnyOfCondition checks list membership`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.AnyOfCondition(listOf("a", "b")), "b")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.AnyOfCondition(listOf("a", "b")), "c")).isFalse() + } + + @Test + fun `NoneOfCondition checks list non-membership`() { + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NoneOfCondition(listOf("a", "b")), "c")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(NotificationExpression.NoneOfCondition(listOf("a", "b")), "a")).isFalse() + } + + @Test + fun `NotCondition negates result`() { + val expression = NotificationExpression.NotCondition(NotificationExpression.ComparisonCondition("test")) + assertThat(RulesEngine.evaluateNotificationExpression(expression, "test")).isFalse() + assertThat(RulesEngine.evaluateNotificationExpression(expression, "other")).isTrue() + } + + @Test + fun `OrCondition returns true if any condition matches`() { + val expression = NotificationExpression.OrCondition( + listOf(NotificationExpression.ComparisonCondition("a"), NotificationExpression.ComparisonCondition("b")) + ) + assertThat(RulesEngine.evaluateNotificationExpression(expression, "a")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(expression, "c")).isFalse() + } + + @Test + fun `AndCondition returns true only if all conditions match`() { + val expression = NotificationExpression.AndCondition( + listOf(NotificationExpression.GreaterThanCondition("1.0"), NotificationExpression.LessThanCondition("3.0")) + ) + assertThat(RulesEngine.evaluateNotificationExpression(expression, "2.0")).isTrue() + assertThat(RulesEngine.evaluateNotificationExpression(expression, "4.0")).isFalse() + } +} From 4386cd18de5003cba2a8e993f1a2d0dabe2abca0 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 15 Jan 2026 22:49:00 +0000 Subject: [PATCH 05/44] Updating version to 3.102 --- .changes/3.102.json | 5 +++++ CHANGELOG.md | 2 ++ gradle.properties | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changes/3.102.json diff --git a/.changes/3.102.json b/.changes/3.102.json new file mode 100644 index 00000000000..39d92c1e9bd --- /dev/null +++ b/.changes/3.102.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-01-15", + "version" : "3.102", + "entries" : [ ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5760b5604..f1f996d2042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +# _3.102_ (2026-01-15) + # _3.101_ (2026-01-09) - **(Bug Fix)** fix: automatically clear customization if chat receives access denied on the customization - **(Bug Fix)** Amazon Q: fix issue where changing the customization does not apply to chat until project re-open diff --git a/gradle.properties b/gradle.properties index f60f58f6647..02e8af022de 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.102-SNAPSHOT +toolkitVersion=3.102 # Publish Settings publishToken= From 8d679cfdf9ab2a712689032ff85989c06f027fd3 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 16 Jan 2026 00:47:56 +0000 Subject: [PATCH 06/44] Updating SNAPSHOT version to 3.103-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 02e8af022de..69ce186ce5b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.102 +toolkitVersion=3.103-SNAPSHOT # Publish Settings publishToken= From 376369867b68e5370c7c4d1576812ee782261389 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:41:09 -0800 Subject: [PATCH 07/44] fix(amazonq): reduce number of calls sent to ui before it is ready (#6206) * fix: reduce number of calls sent to ui before it is ready * fix(amazonq): fix ci builds --------- Co-authored-by: Manodnya Bhoite --- plugins/amazonq/mynah-ui/package-lock.json | 14 +--------- .../lsp/flareChat/ChatCommunicationManager.kt | 28 +++++++++++++++---- plugins/core/webview/package-lock.json | 15 +--------- 3 files changed, 24 insertions(+), 33 deletions(-) diff --git a/plugins/amazonq/mynah-ui/package-lock.json b/plugins/amazonq/mynah-ui/package-lock.json index 9713c3ae903..829f7f5bb36 100644 --- a/plugins/amazonq/mynah-ui/package-lock.json +++ b/plugins/amazonq/mynah-ui/package-lock.json @@ -366,8 +366,7 @@ "node_modules/@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "peer": true + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" }, "node_modules/@types/sanitize-html": { "version": "2.9.5", @@ -423,7 +422,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -777,7 +775,6 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -816,7 +813,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -990,7 +986,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -1479,7 +1474,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2709,7 +2703,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -3010,7 +3003,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.70.0.tgz", "integrity": "sha512-uUxNQ3zAHeAx5nRFskBnrWzDUJrrvpCPD5FNAoRvTi0WwremlheES3tg+56PaVtCs5QDRX5CBLxxKMDJMEa1WQ==", "dev": true, - "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -3085,7 +3077,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3499,7 +3490,6 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3614,7 +3604,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -3661,7 +3650,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt index 0fbb1553a21..2f654cb8207 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt @@ -9,8 +9,6 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import org.eclipse.lsp4j.ProgressParams import org.eclipse.lsp4j.jsonrpc.ResponseErrorException import software.aws.toolkits.core.utils.getLogger @@ -39,16 +37,21 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @Service(Service.Level.PROJECT) -class ChatCommunicationManager(private val project: Project, private val cs: CoroutineScope) { +class ChatCommunicationManager(private val project: Project) { val uiReady = CompletableDeferred() private val chatPartialResultMap = ConcurrentHashMap() private val inflightRequestByTabId = ConcurrentHashMap>() private val pendingSerializedChatRequests = ConcurrentHashMap>() private val pendingTabRequests = ConcurrentHashMap>() private val openTabs = mutableSetOf() + private val pendingMessages = ArrayDeque() fun setUiReady() { uiReady.complete(true) + synchronized(pendingMessages) { + pendingMessages.forEach { chatUpdateCallback?.invoke(it) } + pendingMessages.clear() + } } private var chatUpdateCallback: ((FlareUiMessage) -> Unit)? = null @@ -58,10 +61,23 @@ class ChatCommunicationManager(private val project: Project, private val cs: Cor } fun notifyUi(uiMessage: FlareUiMessage) { - cs.launch { - uiReady.await() - chatUpdateCallback?.invoke(uiMessage) + if (!uiReady.isCompleted) { + synchronized(pendingMessages) { + if (!uiReady.isCompleted) { // Double-check + if (uiMessage.command == "aws/chat/sendContextCommands") { + val removed = pendingMessages.removeAll { it.command == "aws/chat/sendContextCommands" } + if (removed) { + LOG.warn { "Removed old aws/chat/sendContextCommands message(s) before UI ready" } + } + } + pendingMessages.addLast(uiMessage) + return + } + } } + + // UI is ready, invoke immediately + chatUpdateCallback?.invoke(uiMessage) } fun setInflightRequestForTab(tabId: String, result: CompletableFuture) { diff --git a/plugins/core/webview/package-lock.json b/plugins/core/webview/package-lock.json index d0fd3258687..5d32af8fda8 100644 --- a/plugins/core/webview/package-lock.json +++ b/plugins/core/webview/package-lock.json @@ -304,8 +304,7 @@ "node_modules/@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", - "peer": true + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==" }, "node_modules/@types/sanitize-html": { "version": "2.11.0", @@ -361,7 +360,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -810,7 +808,6 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -849,7 +846,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1032,7 +1028,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -1527,7 +1522,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2789,7 +2783,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -3090,7 +3083,6 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", "dev": true, - "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -3165,7 +3157,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3579,7 +3570,6 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3658,7 +3648,6 @@ "version": "3.4.20", "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.20.tgz", "integrity": "sha512-xF4zDKXp67NjgORFX/HOuaiaKYjgxkaToK0KWglFQEYlCw9AqgBlj1yu5xa6YaRek47w2IGiuvpvrGg/XuQFCw==", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.4.20", "@vue/compiler-sfc": "3.4.20", @@ -3747,7 +3736,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -3794,7 +3782,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", From f9eb8f52aee7a2c3a84d747f792145898bc8f24c Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:36:36 -0800 Subject: [PATCH 08/44] fix(amazonq): Can't remove document listener (#6214) * fix(amazonq): Can't remove document listener * fix: updating the mockitoKotlin version for fixing ci builds * fix: upgrade mockito-kotlin to 6.2.3 and mockito-core to 5.20.0 - Update mockito from 5.12.0 to 5.20.0 - Update mockitoKotlin from 5.4.0 to 6.2.3 - Remove snapshot repository since we're using stable releases - Update CodeWhispererTestBase.kt to use mockito-kotlin 6.x API syntax This fixes failing CI tests caused by downgrading from 5.4.1-SNAPSHOT. --- gradle/libs.versions.toml | 4 +-- .../codewhisperer/CodeWhispererTestBase.kt | 25 ++++++++++--------- .../TextDocumentServiceHandler.kt | 16 +++--------- settings.gradle.kts | 6 ----- 4 files changed, 18 insertions(+), 33 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2a32f799b5..2888f5c9b1f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,8 +23,8 @@ kotlin = "2.2.0" # set in /settings.gradle.kts kotlinCoroutines = "1.10.1" lsp4j = "0.24.0" -mockito = "5.12.0" -mockitoKotlin = "5.4.1-SNAPSHOT" +mockito = "5.20.0" +mockitoKotlin = "6.2.3" mockk = "1.13.17" nimbus-jose-jwt = "9.40" node-gradle = "7.0.2" diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt index 80f2bf42a55..4f8ea387c11 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt @@ -316,18 +316,8 @@ open class CodeWhispererTestBase { val psiFileCaptor = argumentCaptor() val latencyContextCaptor = argumentCaptor() - doSuspendableAnswer { - val requestContext = codewhispererService.getRequestContext( - triggerTypeCaptor.firstValue, - editorCaptor.firstValue, - projectRule.project, - psiFileCaptor.firstValue, - latencyContextCaptor.firstValue - ) - projectRule.fixture.type(userInput) - requestContext - }.doCallRealMethod() - .wheneverBlocking(codewhispererService) { + codewhispererService.stub { + onBlocking { getRequestContext( triggerTypeCaptor.capture(), editorCaptor.capture(), @@ -335,7 +325,18 @@ open class CodeWhispererTestBase { psiFileCaptor.capture(), latencyContextCaptor.capture() ) + } doSuspendableAnswer { + val requestContext = codewhispererService.getRequestContext( + triggerTypeCaptor.firstValue, + editorCaptor.firstValue, + projectRule.project, + psiFileCaptor.firstValue, + latencyContextCaptor.firstValue + ) + projectRule.fixture.type(userInput) + requestContext } + } } fun mockLspInlineCompletionResponse(response: InlineCompletionListWithReferences) { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt index 6334126fcc8..6fa183eb7ca 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -16,7 +16,6 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Key import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.VirtualFileManager @@ -86,21 +85,12 @@ class TextDocumentServiceHandler( realTimeEdit(event) } } - ApplicationManager.getApplication().runReadAction { - FileDocumentManager.getInstance().getDocument(file)?.addDocumentListener(listener) + val document = ApplicationManager.getApplication().runReadAction { + FileDocumentManager.getInstance().getDocument(file) } + document?.addDocumentListener(listener, this) file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, listener) - Disposer.register(this) { - ApplicationManager.getApplication().runReadAction { - val existingListener = file.getUserData(KEY_REAL_TIME_EDIT_LISTENER) - if (existingListener != null) { - tryOrNull { FileDocumentManager.getInstance().getDocument(file)?.removeDocumentListener(existingListener) } - file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, null) - } - } - } - trySendIfValid { languageServer -> toUriString(file)?.let { uri -> languageServer.textDocumentService.didOpen( diff --git a/settings.gradle.kts b/settings.gradle.kts index 3c13798d46c..ee1a836d949 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -50,12 +50,6 @@ dependencyResolutionManagement { repositories { codeArtifactMavenRepo() mavenCentral() - maven { - url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") - content { - includeGroupByRegex("org\\.mockito\\.kotlin") - } - } intellijPlatform { defaultRepositories() From 019321d2ff153a0a30e3a144545efb80481a0a7b Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:48:02 -0500 Subject: [PATCH 09/44] =?UTF-8?q?fix(toolkit):=20region/credential=20picke?= =?UTF-8?q?r=20dropdown=20not=20closing=20after=20sel=E2=80=A6=20(#6222)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(toolkit): region/credential picker dropdown not closing after selection --------- Co-authored-by: Jacob Chung --- .../ConnectionSettingsMenuBuilder.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt index bf23a37489e..6fdc40beadc 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/credentials/ConnectionSettingsMenuBuilder.kt @@ -10,8 +10,6 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.actionSystem.Separator -import com.intellij.openapi.actionSystem.ToggleAction -import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project import software.aws.toolkits.core.credentials.CredentialIdentifier @@ -72,7 +70,7 @@ class ConnectionSettingsMenuBuilder private constructor() { val actions = when (settings) { is SelectableIdentitySelectionSettings -> { connections.map { - object : DumbAwareToggleAction( + object : DumbAwareSelectAction( title = it.label, value = it, selected = it == settings.currentSelection, @@ -196,18 +194,20 @@ class ConnectionSettingsMenuBuilder private constructor() { // Helper actions, note: these are public to help make tests easier by leveraging instanceOf checks - abstract inner class DumbAwareToggleAction( + abstract inner class DumbAwareSelectAction( title: String, val value: T, private val selected: Boolean, private val onSelect: (T) -> Unit, - ) : ToggleAction(title), DumbAware { + ) : DumbAwareAction(title) { override fun getActionUpdateThread() = ActionUpdateThread.BGT - override fun isSelected(e: AnActionEvent): Boolean = selected + override fun update(e: AnActionEvent) { + e.presentation.icon = if (selected) AllIcons.Actions.Checked else null + } - override fun setSelected(e: AnActionEvent, state: Boolean) { - if (!isSelected(e)) { + override fun actionPerformed(e: AnActionEvent) { + if (!selected) { onSelect.invoke(value) } } @@ -217,13 +217,13 @@ class ConnectionSettingsMenuBuilder private constructor() { value: AwsRegion, selected: Boolean, onSelect: (AwsRegion) -> Unit, - ) : DumbAwareToggleAction(value.displayName, value, selected, onSelect) + ) : DumbAwareSelectAction(value.displayName, value, selected, onSelect) inner class SwitchCredentialsAction( value: CredentialIdentifier, selected: Boolean, onSelect: (CredentialIdentifier) -> Unit, - ) : DumbAwareToggleAction(value.displayName, value, selected, onSelect) + ) : DumbAwareSelectAction(value.displayName, value, selected, onSelect) inner class IndividualIdentityActionGroup(private val value: AwsBearerTokenConnection) : DefaultActionGroup( From 73ed8986c8a16fff96db7e37aab764000a8ed7ac Mon Sep 17 00:00:00 2001 From: chungjac Date: Mon, 9 Feb 2026 10:59:30 -0800 Subject: [PATCH 10/44] fix(tests): downgrade mockito to 5.x for CI stability (#6232) * fix(tests): downgrade mockito to 5.12.0 and mockito-kotlin to 5.4.0 for CI stability Downgrade from mockito-kotlin 6.2.3 to 5.4.0 (latest stable 5.x) and mockito-core from 5.20.0 to 5.12.0 to restore CI build stability without requiring extensive test migration to mockito-kotlin 6.x API. * fix(tests): downgrade mockito-kotlin to 5.4.0 for CI stability Downgrade mockito-kotlin from 6.2.3 to 5.4.0 (latest stable 5.x) to restore CI build stability without requiring extensive test migration to mockito-kotlin 6.x API. Keep mockito-core at 5.20.0. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2888f5c9b1f..345b1a82036 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ kotlin = "2.2.0" kotlinCoroutines = "1.10.1" lsp4j = "0.24.0" mockito = "5.20.0" -mockitoKotlin = "6.2.3" +mockitoKotlin = "5.4.0" mockk = "1.13.17" nimbus-jose-jwt = "9.40" node-gradle = "7.0.2" From 2f3168199f80fbc7c35f3de5821a086aef5ea3ed Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:52:49 -0800 Subject: [PATCH 11/44] fix(amazonq): Handle directory paths in user-configured Node.js runtime settings (#6217) * fix: Handle directory paths in user-configured Node.js runtime settings * fix: fix unit tests cases * fix: Unit test cases --------- Co-authored-by: chungjac --- .../src/main/kotlin/toolkit-detekt.gradle.kts | 2 +- .../clients/AmazonQStreamingClientTest.kt | 9 ++- .../CodeWhispererCodeModernizerTestBase.kt | 7 ++ .../CodeWhispererSettingsTest.kt | 3 +- .../services/amazonq/lsp/NodeExePatcher.kt | 41 +++++++++- .../amazonq/lsp/NodeExePatcherTest.kt | 80 +++++++++++++++++++ .../TextDocumentServiceHandlerTest.kt | 9 +++ .../profiles/ProfileWatcherTest.kt | 2 + .../lambda/java/JavaLambdaBuilderTest.kt | 5 ++ 9 files changed, 150 insertions(+), 8 deletions(-) diff --git a/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts b/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts index 4ada9845271..9acd873a38a 100644 --- a/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-detekt.gradle.kts @@ -19,7 +19,7 @@ dependencies { // detekt with type introspection configured in kotlin conventions private val detektFiles = fileTree(projectDir).matching { include("**/*.kt", "**/*.kts") - exclude("**/build") + exclude("**/build", "**/bin") } detekt { diff --git a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt index fd93cc89c12..63d57530e4a 100644 --- a/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt +++ b/plugins/amazonq/chat/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/clients/AmazonQStreamingClientTest.kt @@ -59,9 +59,11 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { override fun setup() { super.setup() - // Allow Python paths on Windows for test environment (Python plugin scans for interpreters) + // Allow Python paths for test environment (Python plugin scans for interpreters) if (SystemInfo.isWindows) { VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } else { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin") } amazonQStreamingClient = AmazonQStreamingClient.getInstance(projectRule.project) @@ -245,9 +247,12 @@ class AmazonQStreamingClientTest : AmazonQTestBase() { companion object { @JvmStatic @BeforeClass - fun allowWindowsPythonPaths() { + fun allowPythonPaths() { + // Allow Python paths for test environment (Python plugin scans for interpreters) if (SystemInfo.isWindows) { VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "C:/Program Files") + } else { + VfsRootAccess.allowRootAccess(Disposer.newDisposable(), "/usr/bin", "/usr/local/bin") } } diff --git a/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt b/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt index 6ba37aa6b9f..35e914bdb04 100644 --- a/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt +++ b/plugins/amazonq/codetransform/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codemodernizer/CodeWhispererCodeModernizerTestBase.kt @@ -246,6 +246,13 @@ open class CodeWhispererCodeModernizerTestBase( @Before open fun setup() { + // Allow Python paths for test environment (Python plugin scans for interpreters) + if (com.intellij.openapi.util.SystemInfo.isWindows) { + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } else { + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin") + } + project = projectRule.project toolkitConnectionManager = spy(ToolkitConnectionManager.getInstance(project)) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt index 1adcd2a795e..17270ca043a 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt @@ -73,7 +73,8 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() { ) ToolWindowManager.getInstance(projectRule.project).registerToolWindow( RegisterToolWindowTask( - id = CodeWhispererCodeReferenceToolWindowFactory.id + id = CodeWhispererCodeReferenceToolWindowFactory.id, + shouldBeAvailable = false ) ) projectRule.project.service().updateWidget(CodeWhispererStatusBarWidgetFactory::class.java) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt index 938953d2175..93cad6c64a5 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcher.kt @@ -84,10 +84,14 @@ object NodeExePatcher { // attempt to use user provided node runtime path val nodeRuntime = LspSettings.getInstance().getNodeRuntimePath() if (!nodeRuntime.isNullOrEmpty()) { - LOG.info { "Using node from $nodeRuntime " } - - resolveNodeMetric(false, true) - return Path.of(nodeRuntime) + val userNodePath = resolveNodeExecutable(Path.of(nodeRuntime)) + if (userNodePath != null && validateNode(userNodePath) != null) { + LOG.info { "Using node from $userNodePath" } + resolveNodeMetric(false, true) + return userNodePath + } else { + LOG.warn { "User-specified node runtime path is invalid: $nodeRuntime" } + } } // attempt to use bundled node @@ -126,6 +130,35 @@ object NodeExePatcher { } } + /** + * Resolves a path to a Node.js executable. + * If the path is a directory, looks for node.exe (Windows) or node (Unix) inside it. + * If the path is a file, returns it if it exists and is executable. + * + * @param path The path to resolve (can be a directory or file path) + * @return Path? The resolved path to the node executable, or null if not found + */ + private fun resolveNodeExecutable(path: Path): Path? { + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + + return when { + Files.isDirectory(path) -> { + val nodePath = path.resolve(exeName) + if (Files.isRegularFile(nodePath) && Files.isExecutable(nodePath)) { + nodePath.toAbsolutePath() + } else { + LOG.debug { "Node executable not found in directory: $path" } + null + } + } + Files.isRegularFile(path) && Files.isExecutable(path) -> path.toAbsolutePath() + else -> { + LOG.debug { "Invalid node path: $path (not a file or directory)" } + null + } + } + } + /** * Locates node executable ≥18 in system PATH. * Uses IntelliJ's PathEnvironmentVariableUtil to find executables. diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt index 9ee22f23d52..407b657fa8d 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/NodeExePatcherTest.kt @@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.ProcessOutput import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.util.SystemInfo import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.rules.TempDirectory import com.intellij.testFramework.utils.io.createFile @@ -13,6 +14,7 @@ import com.intellij.util.system.CpuArch import io.mockk.every import io.mockk.junit4.MockKRule import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.slot import org.assertj.core.api.Assertions.assertThat @@ -22,6 +24,7 @@ import org.junit.Test import software.aws.toolkits.core.rules.EnvironmentVariableHelper import software.aws.toolkits.core.utils.exists import software.aws.toolkits.jetbrains.services.cwc.controller.chat.telemetry.getStartUrl +import software.aws.toolkits.jetbrains.settings.LspSettings import java.nio.file.Paths class NodeExePatcherTest { @@ -122,4 +125,81 @@ class NodeExePatcherTest { assertThat(NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode))) .isNotEqualTo(Paths.get(pathToNode)) } + + @Test + fun `getNodeRuntimePath res olves node executable from user-provided directory path`() { + // Create a directory with node executable inside + val nodeDir = tempDir.newDirectory("nodejs").toPath().toAbsolutePath() + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + val nodeExe = Paths.get(nodeDir.toString(), exeName).createFile() + nodeExe.toFile().setExecutable(true) + + // Mock LspSettings to return the directory path (not the executable) + val mockLspSettings = mockk() + every { mockLspSettings.getNodeRuntimePath() } returns nodeDir.toString() + + mockkObject(LspSettings.Companion) + every { LspSettings.getInstance() } returns mockLspSettings + + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + val result = NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode)) + + // Should resolve to the node executable inside the directory + assertThat(result).isEqualTo(nodeExe.toAbsolutePath()) + } + + @Test + fun `getNodeRuntimePath falls back when user-provided directory has no node executable`() { + // Create an empty directory + val emptyDir = tempDir.newDirectory("empty-nodejs").toPath().toAbsolutePath() + + // Create a bundled node as fallback + val bundledNode = tempDir.newFile("bundled-node").toPath().toAbsolutePath() + + // Mock LspSettings to return the empty directory path + val mockLspSettings = mockk() + every { mockLspSettings.getNodeRuntimePath() } returns emptyDir.toString() + + mockkObject(LspSettings.Companion) + every { LspSettings.getInstance() } returns mockLspSettings + + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + val result = NodeExePatcher.getNodeRuntimePath(mockk(), bundledNode) + + // Should fall back to bundled node since directory has no node executable + assertThat(result).isEqualTo(bundledNode) + } + + @Test + fun `getNodeRuntimePath uses user-provided executable path directly`() { + // Create a node executable file directly + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + val nodeExe = tempDir.newFile(exeName).toPath().toAbsolutePath() + nodeExe.toFile().setExecutable(true) + + // Mock LspSettings to return the executable path directly + val mockLspSettings = mockk() + every { mockLspSettings.getNodeRuntimePath() } returns nodeExe.toString() + + mockkObject(LspSettings.Companion) + every { LspSettings.getInstance() } returns mockLspSettings + + mockkStatic(ExecUtil::class) + mockkStatic(::getStartUrl) + every { getStartUrl(any()) } returns "https://start.url" + every { ExecUtil.execAndGetOutput(any(), any()) } returns ProcessOutput("v99.0.0", "", 0, false, false) + + val result = NodeExePatcher.getNodeRuntimePath(mockk(), Paths.get(pathToNode)) + + // Should use the user-provided executable path + assertThat(result).isEqualTo(nodeExe.toAbsolutePath()) + } } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt index bde4e2e989e..bea48da6d46 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -9,9 +9,11 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess import com.intellij.openapi.vfs.writeText import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.LightVirtualFile @@ -79,6 +81,13 @@ class TextDocumentServiceHandlerTest { @Before fun setup() { + // Allow Python paths for test environment (Python plugin scans for interpreters) + if (SystemInfo.isWindows) { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "C:/Program Files") + } else { + VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin") + } + mockTextDocumentService = mockk() mockLanguageServer = mockk() diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt index cc4fd97f242..6c3404fed1a 100644 --- a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/credentials/profiles/ProfileWatcherTest.kt @@ -97,7 +97,9 @@ class ProfileWatcherTest { } } + // Flaky test - file watcher timing is not deterministic under high system load @Test + @org.junit.Ignore("Flaky: FileWatcher timing is not deterministic") fun `watcher is notified on deletion`() { profileFile.parentFile.mkdirs() profileFile.writeText("Test") diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt index 6e5cdd0d037..89ae2e3f5e7 100644 --- a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/lambda/java/JavaLambdaBuilderTest.kt @@ -3,10 +3,12 @@ package software.aws.toolkits.jetbrains.services.lambda.java +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.roots.ModuleRootManagerEx import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.testFramework.IdeaTestUtil +import com.intellij.testFramework.common.ThreadLeakTracker import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -40,6 +42,9 @@ class JavaLambdaBuilderTest { setSamExecutableFromEnvironment() projectRule.fixture.addModule("main") + + // Maven server threads may leak during test execution + ThreadLeakTracker.longRunningThreadCreated(ApplicationManager.getApplication(), "BaseDataReader", "RemoteMavenServer", "Reader thread") } @Test From c1f05c2a51996a189aeac73656ac247ccc195a55 Mon Sep 17 00:00:00 2001 From: chungjac Date: Wed, 11 Feb 2026 12:18:27 -0800 Subject: [PATCH 12/44] deps: deprecate IDE 243 / GW 252 (#6246) --- .../deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json | 4 ++++ .../jetbrains/core/notification/MinimumVersionChange.kt | 4 ++-- .../toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 .changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json diff --git a/.changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json b/.changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json new file mode 100644 index 00000000000..1829bf8928b --- /dev/null +++ b/.changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json @@ -0,0 +1,4 @@ +{ + "type" : "deprecation", + "description" : "An upcoming release will remove support for JetBrains Gateway version 2025.2 and for IDEs based on the 2024.3 platform" +} \ No newline at end of file diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt index 19559ace910..1c5e1da86c8 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/notification/MinimumVersionChange.kt @@ -55,8 +55,8 @@ class MinimumVersionChange @JvmOverloads constructor(isUnderTest: Boolean = fals } companion object { - const val MIN_VERSION = 243 - const val MIN_VERSION_HUMAN = "2024.3" + const val MIN_VERSION = 251 + const val MIN_VERSION_HUMAN = "2025.1" // Used by tests to make sure the prompt never shows up const val SKIP_PROMPT = "aws.suppress_deprecation_prompt" diff --git a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt b/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt index e3c627e34b1..d1576bbbea0 100644 --- a/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt +++ b/plugins/toolkit/jetbrains-gateway/src/software/aws/toolkits/jetbrains/gateway/GatewayDeprecationNotice.kt @@ -30,7 +30,7 @@ class GatewayDeprecationNotice : AppLifecycleListener { } companion object { - const val MIN_VERSION = 252 - const val MIN_VERSION_HUMAN = "2025.2" + const val MIN_VERSION = 253 + const val MIN_VERSION_HUMAN = "2025.3" } } From 188158753462920ec573efd2fd19b8e39a85845a Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:57:39 -0800 Subject: [PATCH 13/44] fix(amazonq): adding changelog (#6247) Co-authored-by: chungjac --- .../bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json | 4 ++++ .../bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json | 4 ++++ .../bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json | 4 ++++ 3 files changed, 12 insertions(+) create mode 100644 .changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json create mode 100644 .changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json create mode 100644 .changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json diff --git a/.changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json b/.changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json new file mode 100644 index 00000000000..833deea01d4 --- /dev/null +++ b/.changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Amazon Q: Fix race condition causing \"Can't remove document listener\" error during LSP restart" +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json b/.changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json new file mode 100644 index 00000000000..d13cdac33ff --- /dev/null +++ b/.changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences" +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json b/.changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json new file mode 100644 index 00000000000..22578809954 --- /dev/null +++ b/.changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path" +} \ No newline at end of file From ae9cdeeee32e6865fa1b6cb38f4bb2ce103e5fcb Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:42:15 -0500 Subject: [PATCH 14/44] fix(toolkit): remove gson from banned import and add gson version constraint (#6229) Co-authored-by: Aseem sharma <198968351+aseemxs@users.noreply.github.com> --- .../main/kotlin/toolkit-intellij-subplugin.gradle.kts | 6 ++++++ .../toolkits/gradle/detekt/rules/BannedImportsRule.kt | 10 ---------- .../gradle/detekt/rules/BannedImportsRuleTest.kt | 7 ------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts index a832242651d..8d3a0261920 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts @@ -64,6 +64,12 @@ configurations { useVersion(versionCatalog.findVersion("kotlin").get().toString()) because("resolve kotlin version conflicts in favor of local version catalog") } + + // https://nvd.nist.gov/vuln/detail/cve-2022-25647 + if (requested.group == "com.google.code.gson" && requested.name == "gson") { + useVersion("2.11.0") + because("CVE-2022-25647 requires Gson >= 2.8.9") + } } } } diff --git a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt index 8c083a40637..6692b8c7e6c 100644 --- a/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt +++ b/detekt-rules/src/software/aws/toolkits/gradle/detekt/rules/BannedImportsRule.kt @@ -87,16 +87,6 @@ class BannedImportsRule : Rule() { ) ) } - - if (importedFqName == "com.google.gson.Gson") { - report( - CodeSmell( - issue, - Entity.from(element), - message = "Use jacksonObjectMapper() insted of Gson" - ) - ) - } } } } diff --git a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt index b16f3af3513..e83d9f74802 100644 --- a/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt +++ b/detekt-rules/tst/software/aws/toolkits/gradle/detekt/rules/BannedImportsRuleTest.kt @@ -24,13 +24,6 @@ class BannedImportsRuleTest { .matches { it.id == "BannedImports" && it.message == "Use AssertJ instead of Hamcrest assertions" } } - @Test - fun `Importing Gson fails`() { - assertThat(rule.lint("import com.google.gson.Gson")) - .singleElement() - .matches { it.id == "BannedImports" && it.message == "Use jacksonObjectMapper() insted of Gson" } - } - @Test fun `Importing Kotlin test assert fails`() { assertThat(rule.lint("import kotlin.test.assertTrue")) From abf2e0e3e1880dab0347c0449ac0f60723d77ecd Mon Sep 17 00:00:00 2001 From: David <60020664+dhasani23@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:16:59 -0800 Subject: [PATCH 15/44] add info banner pointing to ATX (#6261) Co-authored-by: David Hasani --- plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts index 11c3c2ab792..f91a272d119 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/tabs/generator.ts @@ -48,7 +48,8 @@ export class TabDataGenerator { ], [ 'codetransform', - `Welcome to Code Transformation! **You can also run transformations from the command line. To install the tool, see the [documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/run-CLI-transformations.html).**`, + `Welcome to Code Transformation! + **ℹ️ AWS Transform custom now available for Java upgrades. Agentic AI that handles version upgrades, SDK migration, and more, and improves with every execution. [Learn more](https://aws.amazon.com/transform/custom/)**`, ], ]) From a7825827c008d5f71d56f2dee206f5bbe86e56ab Mon Sep 17 00:00:00 2001 From: Aseem sharma <198968351+aseemxs@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:53:59 -0800 Subject: [PATCH 16/44] ci: add missing version bump commits for 3.103 release (#6267) * Updating version to 3.103 * Updating SNAPSHOT version to 3.104-SNAPSHOT --------- Co-authored-by: aws-toolkit-automation <> --- .changes/3.103.json | 17 +++++++++++++++++ ...ix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json | 4 ---- ...ix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json | 4 ---- ...ix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json | 4 ---- ...on-8e1c9680-abc5-4e7b-a533-81608311af3b.json | 4 ---- CHANGELOG.md | 6 ++++++ gradle.properties | 2 +- 7 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 .changes/3.103.json delete mode 100644 .changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json delete mode 100644 .changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json delete mode 100644 .changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json delete mode 100644 .changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json diff --git a/.changes/3.103.json b/.changes/3.103.json new file mode 100644 index 00000000000..f9899ac44e9 --- /dev/null +++ b/.changes/3.103.json @@ -0,0 +1,17 @@ +{ + "date" : "2026-02-12", + "version" : "3.103", + "entries" : [ { + "type" : "bugfix", + "description" : "Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences" + }, { + "type" : "bugfix", + "description" : "Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path" + }, { + "type" : "bugfix", + "description" : "Amazon Q: Fix race condition causing \"Can't remove document listener\" error during LSP restart" + }, { + "type" : "deprecation", + "description" : "An upcoming release will remove support for JetBrains Gateway version 2025.2 and for IDEs based on the 2024.3 platform" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json b/.changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json deleted file mode 100644 index 833deea01d4..00000000000 --- a/.changes/next-release/bugfix-c3a7b1a9-1d2e-45cf-971c-513e19b7094e.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "bugfix", - "description" : "Amazon Q: Fix race condition causing \"Can't remove document listener\" error during LSP restart" -} \ No newline at end of file diff --git a/.changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json b/.changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json deleted file mode 100644 index d13cdac33ff..00000000000 --- a/.changes/next-release/bugfix-ce0e09a5-9928-428a-8d0d-466db368ebb5.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "bugfix", - "description" : "Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences" -} \ No newline at end of file diff --git a/.changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json b/.changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json deleted file mode 100644 index 22578809954..00000000000 --- a/.changes/next-release/bugfix-f6e1a4dd-64e2-41c6-8538-a7f2c2c2eb67.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "bugfix", - "description" : "Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path" -} \ No newline at end of file diff --git a/.changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json b/.changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json deleted file mode 100644 index 1829bf8928b..00000000000 --- a/.changes/next-release/deprecation-8e1c9680-abc5-4e7b-a533-81608311af3b.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "deprecation", - "description" : "An upcoming release will remove support for JetBrains Gateway version 2025.2 and for IDEs based on the 2024.3 platform" -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f996d2042..d582c279ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# _3.103_ (2026-02-12) +- **(Bug Fix)** Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences +- **(Bug Fix)** Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path +- **(Bug Fix)** Amazon Q: Fix race condition causing "Can't remove document listener" error during LSP restart +- **(Deprecation)** An upcoming release will remove support for JetBrains Gateway version 2025.2 and for IDEs based on the 2024.3 platform + # _3.102_ (2026-01-15) # _3.101_ (2026-01-09) diff --git a/gradle.properties b/gradle.properties index 69ce186ce5b..91ed140a922 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.103-SNAPSHOT +toolkitVersion=3.104-SNAPSHOT # Publish Settings publishToken= From ce24ff544d7d8e4d1ab075e8587f345dca39375c Mon Sep 17 00:00:00 2001 From: Will Lo <96078566+Will-ShaoHua@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:43:12 -0800 Subject: [PATCH 17/44] build(253): fix build failures from rider failing to resolve test framework (#6268) * fix build failures from rider failing to resolve test framework * patch --- .../toolkit/jetbrains-rider/build.gradle.kts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/plugins/toolkit/jetbrains-rider/build.gradle.kts b/plugins/toolkit/jetbrains-rider/build.gradle.kts index 6e624db252c..95ece3bde30 100644 --- a/plugins/toolkit/jetbrains-rider/build.gradle.kts +++ b/plugins/toolkit/jetbrains-rider/build.gradle.kts @@ -67,6 +67,22 @@ if (providers.gradleProperty("ideProfileName").get() == "2024.3") { } } +if (providers.gradleProperty("ideProfileName").get() == "2025.3") { + configurations.all { + resolutionStrategy.dependencySubstitution { + listOf( + "com.jetbrains.intellij.java:java-test-framework", + "com.jetbrains.intellij.platform:test-framework", + "com.jetbrains.intellij.platform:test-framework-junit5" + ).forEach { + substitute(module(it)) + .using(module("$it:253.28294.334")) + .because("Rider 2025.3.0 requires a newer version of test-framework") + } + } + } +} + configurations { all { exclude(group = "com.jetbrains.intellij.spellchecker") @@ -80,7 +96,7 @@ dependencies { // FIX_WHEN_MIN_IS_251: https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1774 when (providers.gradleProperty("ideProfileName").get()) { - "2024.3", "2025.1" -> { + "2024.3", "2025.1, 2025.3" -> { bundledModule("intellij.rider") } } From 3bec3c0b3ac36574d3e19e44103803e9ee6b8ead Mon Sep 17 00:00:00 2001 From: Will Lo <96078566+Will-ShaoHua@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:21:51 -0800 Subject: [PATCH 18/44] build(ci): windows unit test failure File accessed outside allowed roots: C:/Program Files/pypy3.11-v7.3.20-win64/python.exe (#6273) * fix: windows unit test failure File accessed outside allowed roots: file://C:/Program Files/pypy3.11-v7.3.20-win64/python.exe * r --- .../services/codewhisperer/CodeWhispererTestBase.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt index 4f8ea387c11..cf7c9c3d4c8 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestBase.kt @@ -117,7 +117,13 @@ open class CodeWhispererTestBase { @Before open fun setUp() = runTest { mockLanguageServer = mockk() - VfsRootAccess.allowRootAccess(disposableRule.disposable, "/usr/bin", "/usr/local/bin", "C:/Program Files/pypy3.10-v7.3.17-win64") + VfsRootAccess.allowRootAccess( + disposableRule.disposable, + "/usr/bin", + "/usr/local/bin", + "C:/Program Files/pypy3.10-v7.3.17-win64", + "C:/Program Files/pypy3.11-v7.3.20-win64" + ) val starter = object : AmazonQServerInstanceStarter { override fun start( project: Project, From cf48c44a65326a604a8243a4f146f0c1b83f08c5 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 27 Feb 2026 22:28:02 +0000 Subject: [PATCH 19/44] Updating version to 3.104 --- .changes/3.104.json | 5 +++++ CHANGELOG.md | 2 ++ gradle.properties | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changes/3.104.json diff --git a/.changes/3.104.json b/.changes/3.104.json new file mode 100644 index 00000000000..b4af90336c6 --- /dev/null +++ b/.changes/3.104.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-02-27", + "version" : "3.104", + "entries" : [ ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d582c279ca5..eb2d4a4fbd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +# _3.104_ (2026-02-27) + # _3.103_ (2026-02-12) - **(Bug Fix)** Amazon Q Chat: Fix chat history restoration to display rich UI elements and persist user preferences - **(Bug Fix)** Amazon Q: Fix LSP server startup failure when Node.js runtime path is configured as a directory instead of the executable path diff --git a/gradle.properties b/gradle.properties index 91ed140a922..7bea4dd5a01 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.104-SNAPSHOT +toolkitVersion=3.104 # Publish Settings publishToken= From 811197eb1cb5bb383f5aa32f161ac151b57b5b3c Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Sat, 28 Feb 2026 00:09:49 +0000 Subject: [PATCH 20/44] Updating SNAPSHOT version to 3.105-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 7bea4dd5a01..146f46c5290 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.104 +toolkitVersion=3.105-SNAPSHOT # Publish Settings publishToken= From 8655a2d4fe5b741db3a996cdeb968dd23651fee6 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:02:15 -0800 Subject: [PATCH 21/44] fix(amazonq): Fix for incorrect TriggerToResponseLatencyMilliseconds values in JetBrains telemetry (#6260) * fix(amazonq): adding changelog * fix(amazonq): Fix for incorrect TriggerToResponseLatencyMilliseconds values in JetBrains telemetry --------- Co-authored-by: chungjac --- .../codewhisperer/model/CodeWhispererModel.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt index b13c71652b6..4e27dcef806 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/model/CodeWhispererModel.kt @@ -219,20 +219,31 @@ data class LatencyContext( var firstRequestId: String = "", ) { - fun getCodeWhispererEndToEndLatency() = TimeUnit.NANOSECONDS.toMillis( - codewhispererEndToEndEnd - codewhispererEndToEndStart - ).toDouble() + fun getCodeWhispererEndToEndLatency(): Double { + // Guard against uninitialized timestamps which would result in incorrect latency values + if (codewhispererEndToEndStart == 0L || codewhispererEndToEndEnd == 0L) { + return 0.0 + } + return TimeUnit.NANOSECONDS.toMillis( + codewhispererEndToEndEnd - codewhispererEndToEndStart + ).toDouble() + } // For auto-trigger it's from the time when last char typed // for manual-trigger it's from the time when last trigger action happened(alt + c) - fun getPerceivedLatency(triggerType: CodewhispererTriggerType) = - if (triggerType == CodewhispererTriggerType.OnDemand) { + fun getPerceivedLatency(triggerType: CodewhispererTriggerType): Double { + // Guard against uninitialized timestamps which would result in incorrect latency values + if (codewhispererEndToEndEnd == 0L) { + return 0.0 + } + return if (triggerType == CodewhispererTriggerType.OnDemand) { getCodeWhispererEndToEndLatency() } else { TimeUnit.NANOSECONDS.toMillis( codewhispererEndToEndEnd - CodeWhispererAutoTriggerService.getInstance().timeAtLastCharTyped ).toDouble() } + } } data class TryExampleRowContext( From 800f3e094e45d50fa0f63c9737eeed1e01e70bff Mon Sep 17 00:00:00 2001 From: chungjac Date: Thu, 5 Mar 2026 13:34:50 -0800 Subject: [PATCH 22/44] build: drop support for 243 (#6248) * build: drop support for 243 * fix: remove 2024.3 * fix: FIX_WHEN_MIN_IS_252 --- ...-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json | 4 ++ .github/workflows/prerelease.yml | 2 +- ...n AWS Toolkit - Community [2024.3].run.xml | 25 ----------- ...Run AWS Toolkit - Gateway [2025.2].run.xml | 25 ----------- .run/Run AWS Toolkit - Rider [2024.3].run.xml | 25 ----------- ...un AWS Toolkit - Ultimate [2024.3].run.xml | 25 ----------- .run/Run All - Community [2024.3].run.xml | 25 ----------- .run/Run All - Rider [2024.3].run.xml | 25 ----------- .run/Run All - Ultimate [2024.3].run.xml | 25 ----------- .../Run Amazon Q - Community [2024.3].run.xml | 25 ----------- .run/Run Amazon Q - Rider [2024.3].run.xml | 25 ----------- .run/Run Amazon Q - Ultimate [2024.3].run.xml | 25 ----------- .run/generateConfigs.py | 2 +- .../aws/toolkits/gradle/BuildScriptUtils.kt | 1 - .../toolkits/gradle/intellij/IdeVersions.kt | 43 ------------------- kotlinResolution.settings.gradle.kts | 2 +- .../toolkit/jetbrains-rider/build.gradle.kts | 23 +--------- settings.gradle.kts | 2 +- 18 files changed, 10 insertions(+), 319 deletions(-) create mode 100644 .changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json delete mode 100644 .run/Run AWS Toolkit - Community [2024.3].run.xml delete mode 100644 .run/Run AWS Toolkit - Gateway [2025.2].run.xml delete mode 100644 .run/Run AWS Toolkit - Rider [2024.3].run.xml delete mode 100644 .run/Run AWS Toolkit - Ultimate [2024.3].run.xml delete mode 100644 .run/Run All - Community [2024.3].run.xml delete mode 100644 .run/Run All - Rider [2024.3].run.xml delete mode 100644 .run/Run All - Ultimate [2024.3].run.xml delete mode 100644 .run/Run Amazon Q - Community [2024.3].run.xml delete mode 100644 .run/Run Amazon Q - Rider [2024.3].run.xml delete mode 100644 .run/Run Amazon Q - Ultimate [2024.3].run.xml diff --git a/.changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json b/.changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json new file mode 100644 index 00000000000..8405092e2b5 --- /dev/null +++ b/.changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json @@ -0,0 +1,4 @@ +{ + "type" : "removal", + "description" : "Removed support for 2024.3.x IDEs and Gateway 2025.2" +} \ No newline at end of file diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 32bd1956813..1af665757ae 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: build_target: [ ':plugin-core:buildPlugin', ':plugin-toolkit:intellij-standalone:buildPlugin', ':plugin-amazonq:buildPlugin' ] - version: [ '2024.3', '2025.1', '2025.2', '2025.3' ] + version: [ '2025.1', '2025.2', '2025.3' ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.run/Run AWS Toolkit - Community [2024.3].run.xml b/.run/Run AWS Toolkit - Community [2024.3].run.xml deleted file mode 100644 index ffe9b21a4b3..00000000000 --- a/.run/Run AWS Toolkit - Community [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Gateway [2025.2].run.xml b/.run/Run AWS Toolkit - Gateway [2025.2].run.xml deleted file mode 100644 index 173f374a06b..00000000000 --- a/.run/Run AWS Toolkit - Gateway [2025.2].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Rider [2024.3].run.xml b/.run/Run AWS Toolkit - Rider [2024.3].run.xml deleted file mode 100644 index 36f24db1b98..00000000000 --- a/.run/Run AWS Toolkit - Rider [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run AWS Toolkit - Ultimate [2024.3].run.xml b/.run/Run AWS Toolkit - Ultimate [2024.3].run.xml deleted file mode 100644 index bbb485b5902..00000000000 --- a/.run/Run AWS Toolkit - Ultimate [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Community [2024.3].run.xml b/.run/Run All - Community [2024.3].run.xml deleted file mode 100644 index 988ece48c18..00000000000 --- a/.run/Run All - Community [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Rider [2024.3].run.xml b/.run/Run All - Rider [2024.3].run.xml deleted file mode 100644 index ba5df9f5ab5..00000000000 --- a/.run/Run All - Rider [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run All - Ultimate [2024.3].run.xml b/.run/Run All - Ultimate [2024.3].run.xml deleted file mode 100644 index 49f02c1a438..00000000000 --- a/.run/Run All - Ultimate [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Community [2024.3].run.xml b/.run/Run Amazon Q - Community [2024.3].run.xml deleted file mode 100644 index 4f6b86d32f1..00000000000 --- a/.run/Run Amazon Q - Community [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Rider [2024.3].run.xml b/.run/Run Amazon Q - Rider [2024.3].run.xml deleted file mode 100644 index 9e64b94c3e3..00000000000 --- a/.run/Run Amazon Q - Rider [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/Run Amazon Q - Ultimate [2024.3].run.xml b/.run/Run Amazon Q - Ultimate [2024.3].run.xml deleted file mode 100644 index 6891367056b..00000000000 --- a/.run/Run Amazon Q - Ultimate [2024.3].run.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - false - true - false - false - - - \ No newline at end of file diff --git a/.run/generateConfigs.py b/.run/generateConfigs.py index 9cf67342b79..f21d389662e 100644 --- a/.run/generateConfigs.py +++ b/.run/generateConfigs.py @@ -52,7 +52,7 @@ def write_config(mv: str, ide: IdeVariant, plugin: PluginVariant): f.write(TEMPLATE.format(plugin = plugin, variant = ide, major_version = mv)) if __name__ == '__main__': - mvs = ["2024.3", "2025.1", "2025.2", "2025.3"] + mvs = ["2025.1", "2025.2", "2025.3"] ides = [ IdeVariant("Community", "IC"), IdeVariant("Rider", "RD"), diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt index 30d3187286c..96c016a286d 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/BuildScriptUtils.kt @@ -28,7 +28,6 @@ fun Project.jvmTarget(): Provider = withCurrentProfileName { // https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#stdlib-miscellaneous fun Project.kotlinTarget(): Provider = withCurrentProfileName { when (it) { - "2024.3" -> KotlinVersionEnum.KOTLIN_2_0 "2025.1", "2025.2", "2025.3" -> KotlinVersionEnum.KOTLIN_2_1 else -> error("not set") }.version diff --git a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt index 4ed6d061bfa..b0930bc93ab 100644 --- a/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt +++ b/buildSrc/src/main/kotlin/software/aws/toolkits/gradle/intellij/IdeVersions.kt @@ -26,45 +26,6 @@ object IdeVersions { ) private val ideProfiles = listOf( - Profile( - name = "2024.3", - community = ProductProfile( - sdkVersion = "2024.3", - bundledPlugins = commonPlugins + listOf( - "com.intellij.java", - "com.intellij.gradle", - "org.jetbrains.idea.maven", - ), - marketplacePlugins = listOf( - "org.toml.lang:243.21565.122", - "PythonCore:243.21565.211", - "Docker:243.21565.204", - "com.intellij.modules.json:243.26574.91" - ) - ), - ultimate = ProductProfile( - sdkVersion = "2024.3", - bundledPlugins = commonPlugins + listOf( - "JavaScript", - "JavaScriptDebugger", - "com.intellij.database", - "com.jetbrains.codeWithMe", - ), - marketplacePlugins = listOf( - "org.toml.lang:243.21565.122", - "Pythonid:243.21565.211", - "org.jetbrains.plugins.go:243.21565.211", - "com.intellij.modules.json:243.26574.91" - ) - ), - rider = RiderProfile( - sdkVersion = "2024.3", - bundledPlugins = commonPlugins, - netFrameworkTarget = "net472", - rdGenVersion = "2024.3.0", - nugetVersion = " 2024.3.0" - ) - ), Profile( name = "2025.1", community = ProductProfile( @@ -109,10 +70,6 @@ object IdeVersions { ), Profile( name = "2025.2", - gateway = ProductProfile( - sdkVersion = "2025.2", - bundledPlugins = listOf("org.jetbrains.plugins.terminal") - ), community = ProductProfile( sdkVersion = "2025.2", bundledPlugins = commonPlugins + listOf( diff --git a/kotlinResolution.settings.gradle.kts b/kotlinResolution.settings.gradle.kts index 3023d2002d2..04c5efe2227 100644 --- a/kotlinResolution.settings.gradle.kts +++ b/kotlinResolution.settings.gradle.kts @@ -6,7 +6,7 @@ dependencyResolutionManagement { maybeCreate("libs").apply { // pull value from IJ library list: https://github.com/JetBrains/intellij-community/blob//.idea/libraries/kotlinx_coroutines_core.xml val version = when (providers.gradleProperty("ideProfileName").getOrNull() ?: return@apply) { - "2024.3", "2025.1" -> { + "2025.1" -> { "1.8.0-intellij-11" } diff --git a/plugins/toolkit/jetbrains-rider/build.gradle.kts b/plugins/toolkit/jetbrains-rider/build.gradle.kts index 95ece3bde30..07d3f161ee9 100644 --- a/plugins/toolkit/jetbrains-rider/build.gradle.kts +++ b/plugins/toolkit/jetbrains-rider/build.gradle.kts @@ -48,25 +48,6 @@ sourceSets { } } -// FIX_WHEN_MIN_IS_251 -// org.gradle.internal.resolve.ModuleVersionNotFoundException: -// Could not find any version that matches com.jetbrains.intellij.platform:test-framework:{strictly [243, 243.21565.192]; prefer 243.21565.192}. -if (providers.gradleProperty("ideProfileName").get() == "2024.3") { - configurations.all { - resolutionStrategy.dependencySubstitution { - listOf( - "com.jetbrains.intellij.java:java-test-framework", - "com.jetbrains.intellij.platform:test-framework", - "com.jetbrains.intellij.platform:test-framework-junit5" - ).forEach { - substitute(module(it)) - .using(module("$it:243.21565.193")) - .because("Rider 2024.3.0 requires a newer version of test-framework") - } - } - } -} - if (providers.gradleProperty("ideProfileName").get() == "2025.3") { configurations.all { resolutionStrategy.dependencySubstitution { @@ -94,9 +75,9 @@ dependencies { localPlugin(project(":plugin-core")) testFramework(TestFrameworkType.Bundled) - // FIX_WHEN_MIN_IS_251: https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1774 + // FIX_WHEN_MIN_IS_253: https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1774 when (providers.gradleProperty("ideProfileName").get()) { - "2024.3", "2025.1, 2025.3" -> { + "2025.1, 2025.3" -> { bundledModule("intellij.rider") } } diff --git a/settings.gradle.kts b/settings.gradle.kts index ee1a836d949..68520bfa5c7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -161,7 +161,7 @@ file("plugins").listFiles()?.forEach root@ { if (it.name == "jetbrains-gateway") { when (providers.gradleProperty("ideProfileName").get()) { // buildSrc is evaluated after settings so we can't key off of IdeVersions.kt - "2024.3", "2025.1" -> { + "2025.1", "2025.2" -> { return@forEach } } From 8c3e8641058ff73990fe25efe94ece06fd3be585 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 6 Mar 2026 00:22:29 +0000 Subject: [PATCH 23/44] Updating version to 3.105 --- .changes/3.105.json | 8 ++++++++ .../removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json | 4 ---- CHANGELOG.md | 3 +++ gradle.properties | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 .changes/3.105.json delete mode 100644 .changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json diff --git a/.changes/3.105.json b/.changes/3.105.json new file mode 100644 index 00000000000..9cbbd8d20f2 --- /dev/null +++ b/.changes/3.105.json @@ -0,0 +1,8 @@ +{ + "date" : "2026-03-06", + "version" : "3.105", + "entries" : [ { + "type" : "removal", + "description" : "Removed support for 2024.3.x IDEs and Gateway 2025.2" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json b/.changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json deleted file mode 100644 index 8405092e2b5..00000000000 --- a/.changes/next-release/removal-6d8da6d5-5620-42d4-89ae-1f2ce5128946.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "removal", - "description" : "Removed support for 2024.3.x IDEs and Gateway 2025.2" -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2d4a4fbd9..862e2009da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# _3.105_ (2026-03-06) +- **(Removal)** Removed support for 2024.3.x IDEs and Gateway 2025.2 + # _3.104_ (2026-02-27) # _3.103_ (2026-02-12) diff --git a/gradle.properties b/gradle.properties index 146f46c5290..f28bdce67ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.105-SNAPSHOT +toolkitVersion=3.105 # Publish Settings publishToken= From d3451eee4a585f3831d9b37ece7bf052d3f44164 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 6 Mar 2026 01:28:04 +0000 Subject: [PATCH 24/44] Updating SNAPSHOT version to 3.106-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index f28bdce67ab..5e0624a8cc8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.105 +toolkitVersion=3.106-SNAPSHOT # Publish Settings publishToken= From f9d938de99082d45f920f8278a926c1d2edc3070 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:51:29 -0700 Subject: [PATCH 25/44] fix(toolkit): lazy-init languageIds in NodeJsRuntimeGroup to prevent NoClassDefFoundError on startup (#6291) * fix(amazonq): adding changelog * fix(amazonq): Fix for incorrect TriggerToResponseLatencyMilliseconds values in JetBrains telemetry * fix: lazy-init languageIds in NodeJsRuntimeGroup to prevent NoClassDefFoundError Fixes #6289 NodeJsRuntimeGroup was eagerly initializing languageIds at construction time, which caused JavascriptLanguage to be loaded immediately when the plugin extension point was instantiated at IDE startup. In IDEs where the JavaScript plugin is unavailable (e.g. PyCharm), this resulted in a NoClassDefFoundError crashing the plugin. Making languageIds a lazy delegate defers the class loading until the property is actually accessed, avoiding the crash on startup. --------- Co-authored-by: chungjac --- .../services/lambda/nodejs/NodeJsRuntimeGroup.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt b/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt index 46a036a334a..e1230ec3867 100644 --- a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt +++ b/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt @@ -17,10 +17,12 @@ import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroup class NodeJsRuntimeGroup : SdkBasedRuntimeGroup() { override val id: String = BuiltInRuntimeGroups.NodeJs - override val languageIds: Set = setOf( - JavascriptLanguage.id, - JavaScriptSupportLoader.ECMA_SCRIPT_6.id - ) + override val languageIds: Set by lazy { + setOf( + JavascriptLanguage.id, + JavaScriptSupportLoader.ECMA_SCRIPT_6.id + ) + } override val supportsPathMappings: Boolean = true override val supportedRuntimes = listOf( From 0ab17aff6039994e26c9bae5b37131f77004973b Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:24:03 -0700 Subject: [PATCH 26/44] fix(toolkit): Adding try catch to catch NoClassDefFoundError (#6294) * fix(amazonq): adding changelog * fix(amazonq): Fix for incorrect TriggerToResponseLatencyMilliseconds values in JetBrains telemetry * fix: lazy-init languageIds in NodeJsRuntimeGroup to prevent NoClassDefFoundError Fixes #6289 NodeJsRuntimeGroup was eagerly initializing languageIds at construction time, which caused JavascriptLanguage to be loaded immediately when the plugin extension point was instantiated at IDE startup. In IDEs where the JavaScript plugin is unavailable (e.g. PyCharm), this resulted in a NoClassDefFoundError crashing the plugin. Making languageIds a lazy delegate defers the class loading until the property is actually accessed, avoiding the crash on startup. * fix: Adding try catch to catch NoClassDefFoundError --------- Co-authored-by: chungjac --- .../services/lambda/nodejs/NodeJsRuntimeGroup.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt b/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt index e1230ec3867..3fabe1af3c6 100644 --- a/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt +++ b/plugins/toolkit/jetbrains-ultimate/src/software/aws/toolkits/jetbrains/services/lambda/nodejs/NodeJsRuntimeGroup.kt @@ -18,10 +18,14 @@ import software.aws.toolkits.jetbrains.services.lambda.SdkBasedRuntimeGroup class NodeJsRuntimeGroup : SdkBasedRuntimeGroup() { override val id: String = BuiltInRuntimeGroups.NodeJs override val languageIds: Set by lazy { - setOf( - JavascriptLanguage.id, - JavaScriptSupportLoader.ECMA_SCRIPT_6.id - ) + try { + setOf( + JavascriptLanguage.id, + JavaScriptSupportLoader.ECMA_SCRIPT_6.id + ) + } catch (e: Throwable) { + emptySet() + } } override val supportsPathMappings: Boolean = true From 8db5d246d49cb02b574eb9396c5e6dff875818a4 Mon Sep 17 00:00:00 2001 From: chungjac Date: Thu, 12 Mar 2026 09:30:44 -0700 Subject: [PATCH 27/44] feat(amazonq): bundle stripped indexing folder for @file support in fallback LSP (#6280) Co-authored-by: invictus <149003065+ashishrp-aws@users.noreply.github.com> --- plugins/amazonq/build.gradle.kts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/amazonq/build.gradle.kts b/plugins/amazonq/build.gradle.kts index c312b50dcdc..11b91c2c838 100644 --- a/plugins/amazonq/build.gradle.kts +++ b/plugins/amazonq/build.gradle.kts @@ -126,6 +126,15 @@ val prepareBundledFlare by tasks.registering(Copy::class) { from(zipTree(it)) { include("*.js") include("*.txt") + // Include stripped indexing folder for @file, @folder, @code support + include("indexing/lspServer.js") + include("indexing/dist/extension.js") + include("indexing/dist/tree-sitter.wasm") + include("indexing/dist/tree-sitter-wasms/**") + // Exclude heavy platform-specific native binaries and models + exclude("indexing/dist/bin/**") + exclude("indexing/dist/build/**") + exclude("indexing/models/**") } } } From f6d785dcb4db6104a2cd773b8c9de948982bdd66 Mon Sep 17 00:00:00 2001 From: Christopher-Neil Mendoza Date: Thu, 12 Mar 2026 19:33:02 -0400 Subject: [PATCH 28/44] feat(cloudformation): merge cloudformationLsp feature branch into main branch (#6304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cloudformation): Add CloudFormation Language Server integration (#6188) * feat(cloudformation): add CloudFormation LSP * update imports as per toolkits to toolkit rename * address PR comments on 1/26 * add didConfigurationChange notification to server * revert unintended package-lock.json changes * Add auto-update, manifest caching, hash verification, and legacy Linux support for LSP server * address comments * reduce class scopes * remove unused imports * add ldconfig lookup and binary check fallback for legacy linux * address detekt issues * create extension config, update init params * feat(cloudformation): add CloudFormation tool window, stacks tree view (#6215) * feat(cloudformation): add CloudFormation tool window and stacks tree view * remove seperate cfn region picker and subscribe to AwsConnectionManager for region info * address PR comments, no major change - 02/05 * fix(cloudformation): rename properties to fix failing test (#6241) * fix(cloudformation): rename properties to fix failing test * use gson serializer to fix detekt issue with PascalCase * feat(cloudformation): add resources exploration node (#6236) * Add CloudFormation resources node * Remove duplicated message bundles * gradle check fixes * Fixed imports and nested data classes * fix(cloudformation): persist resource types list (#6253) * fix(cloudformation): Skip credential resolution during transient connection states (#6252) * feat(cloudformation): Prompt for CloudFormation Language Server telem… (#6249) * feat(cloudformation): Prompt for CloudFormation Language Server telemetry opt-in * update changelog and reuse learn more message bundle * revert to using cfn dedicated learn more message * fix(cloudformation): dedupe resource pagination and add load more right click action (#6256) * feat(cloudformation): validate template via change set and add actions toolbar (#6255) * fix(cloudformation): suppress noisy LSP logMessage notifications from surfacing as balloon popups (#6259) * feat(cloudformation): add stack view panel and overview contents (#6250) * feat(cloudformation): add semantic versioning and limit server versio… (#6262) * feat(cloudformation): add semantic versioning and limit server version for compatibility * use isLatest, remove gh release api * fix(cloudformation): Fix imports and gradle properties (#6272) * feat(cloudformation): Add document manager to list available templates * Lint fixes * Addressed comments * Move DocumentMetadata to manager class, remove relative path parsing logs * Use absolute paths on hover, fix folder icon, fix imports * fix(cloudformation): auto focus search bar, allow for entire checkbox row to be selectable (#6266) * Merge pull request #6265 from Zee2413/lsp-integ-test feat(cloudformation): add cfn lsp integration test * feat(cloudformation): add stack resources panel (#6271) * feat(cloudformation): add stack resources panel * Move auto refresh logic into new listener and interface * feat(cloudformation): Add outputs panel to CloudFormation template (#6275) * feat(cloudformation): Add outputs panel to CloudFormation template * Addressed comments * Fix merge conflicts and api contract changes * feat(cloudformation): view change set diff and add deployment configu… (#6279) * feat(cloudformation): view change set diff and add deployment configurations * revert package lock * add live aware changeset drift in diff view * add titles to tables, rename change set tab, add diff view button * Minor code cleanup and detekt fix * Address PR feedback: 1. Silent catch → Now shows notifyError to the user with the failure reason 2. .get() without timeout → All 5 calls now use get(30, TimeUnit.SECONDS) — existing catch blocks handle TimeoutException 3. Scheduler lifecycle → Added project.isDisposed check at the start of each poll tick, and a MAX_POLL_COUNT of 3600 (1 hour at 1s intervals) to prevent infinite polling * feat(cloudformation): update README with CloudFormation language server features (#6283) * feat(cloudformation): Add stack events panel (#6277) * feat(cloudformation): Add stack events panel * Add changelog * Fixes * Created shared table panel method, using arn sdk methods * Make hook invocation column conditional, set console links dynamically, fix console icon spacing, fix display messages * Remove unused const * UI tweaks * Make changelog more concise * fix(cloudformation): add Node.js download link to error notification * fix(cloudformation): add extension name and version to the lsp init options * fix(cloudformation): convert cfn-guard settings rule pack to be a checkbox list * feat(cloudformation): add status bar showing CloudFormation deployment operations in-flight (#6287) * fix(cloudformation): Start CloudFormation LSP upon toolkit activation (#6293) * fix(cloudformation): Restart polling and switch to events panel after executing change set (#6290) Co-authored-by: invictus <149003065+ashishrp-aws@users.noreply.github.com> * fix(cloudformation): Update plugin description to include CloudFormation support (#6296) * fix(cloudformation): Fix CI (#6301) * feat(cloudformation): Add CloudFormation LSP Introduction notification (#6303) * fix(cloudformation): add credentials listener to cloudformation tool window * fix(cloudformation): Fix CI for 2025-3 version * fix lsp server shutdown * Add robust handling around cfn lsp server shutdown * Added comments * Move cfn resources into correct xml * Move all cfn lsp resources to correct xml * Remove unnecessary whitespace * Add explicit disposal of server following project close * Modify logging * feat(cloudformation): Add CloudFormation LSP Introduction notification * fix(cloudformation): consolidated change logs (#6305) --------- Co-authored-by: aws-toolkit-automation <43144436+aws-toolkit-automation@users.noreply.github.com> Co-authored-by: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Co-authored-by: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Co-authored-by: Zeeshan Ahmed Co-authored-by: invictus <149003065+ashishrp-aws@users.noreply.github.com> --- ...-41a366ff-5293-4924-a772-4096fbcd6c0f.json | 4 + ...-cdbb737a-4589-4e43-8102-33e8a881db59.json | 4 + ...-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json | 4 + .github/workflows/cfn-lsp-integ.yml | 36 + README.md | 22 +- .../kotlin/toolkit-intellij-plugin.gradle.kts | 10 +- .../toolkit-intellij-subplugin.gradle.kts | 10 +- ...oolkit-publish-root-conventions.gradle.kts | 2 +- gradle.properties | 2 +- .../aws/toolkits/jetbrains/ToolkitPlaces.kt | 1 + .../utils/rules/CodeInsightTestFixtureRule.kt | 18 + .../resources/MessagesBundle.properties | 125 +++- .../toolkit/jetbrains-core/build.gradle.kts | 1 + .../services/cfnlsp/CfnLspIntegrationTest.kt | 151 +++++ .../services/cfnlsp/CfnLspTestFixture.kt | 142 ++++ .../aws.toolkit.cloudformation.lsp.xml | 195 ++++++ .../resources/META-INF/plugin.xml | 10 + .../services/cfnlsp/CfnClientService.kt | 210 ++++++ .../services/cfnlsp/CfnCredentialsService.kt | 191 ++++++ .../services/cfnlsp/CfnLspIntroPromptState.kt | 31 + .../services/cfnlsp/CfnLspIntroPrompter.kt | 59 ++ .../services/cfnlsp/CfnLspStartupActivity.kt | 19 + .../cfnlsp/documents/CfnDocumentManager.kt | 41 ++ .../cfnlsp/explorer/CloudFormationRootNode.kt | 28 + .../explorer/CloudFormationToolWindow.kt | 112 ++++ .../explorer/CloudFormationToolWindowTab.kt | 16 + .../explorer/CloudFormationTreeStructure.kt | 15 + .../explorer/actions/DeleteChangeSetAction.kt | 23 + .../explorer/actions/DeployChangeSetAction.kt | 28 + .../explorer/actions/LoadMoreStacksAction.kt | 16 + .../explorer/actions/RefreshAllAction.kt | 18 + .../actions/RefreshChangeSetsAction.kt | 20 + .../explorer/actions/RefreshStacksAction.kt | 15 + .../explorer/actions/ResourceActions.kt | 305 +++++++++ .../explorer/actions/ViewChangeSetAction.kt | 43 ++ .../cfnlsp/explorer/nodes/ChangeSetNodes.kt | 134 ++++ .../cfnlsp/explorer/nodes/ResourceTypeNode.kt | 156 +++++ .../cfnlsp/explorer/nodes/ResourcesNode.kt | 39 ++ .../cfnlsp/explorer/nodes/StackNodes.kt | 126 ++++ .../cfnlsp/resources/ResourceCache.kt | 28 + .../cfnlsp/resources/ResourceLoader.kt | 221 +++++++ .../resources/ResourceNotificationService.kt | 90 +++ .../cfnlsp/resources/ResourceStateEditor.kt | 35 + .../cfnlsp/resources/ResourceStateService.kt | 148 +++++ .../cfnlsp/resources/ResourceTypesManager.kt | 102 +++ .../services/cfnlsp/server/CfnLspClient.kt | 23 + .../server/CfnLspServerSupportProvider.kt | 234 +++++++ .../stacks/CfnOperationStatusService.kt | 157 +++++ .../stacks/CfnStatusBarWidgetFactory.kt | 92 +++ .../stacks/ChangeSetDeletionWorkflow.kt | 78 +++ .../cfnlsp/stacks/ChangeSetsManager.kt | 104 +++ .../cfnlsp/stacks/DeploymentWorkflow.kt | 110 ++++ .../cfnlsp/stacks/LastValidationService.kt | 18 + .../services/cfnlsp/stacks/PollingWorkflow.kt | 83 +++ .../stacks/RerunValidateAndDeployAction.kt | 31 + .../services/cfnlsp/stacks/StacksManager.kt | 127 ++++ .../cfnlsp/stacks/ValidateAndDeployAction.kt | 176 +++++ .../cfnlsp/stacks/ValidationWorkflow.kt | 187 ++++++ .../stacks/views/OpenStackViewAction.kt | 53 ++ .../cfnlsp/stacks/views/StackEventsPanel.kt | 226 +++++++ .../views/StackEventsTableComponents.kt | 292 ++++++++ .../cfnlsp/stacks/views/StackOutputsPanel.kt | 108 +++ .../cfnlsp/stacks/views/StackOverviewPanel.kt | 211 ++++++ .../stacks/views/StackPanelLayoutBuilder.kt | 267 ++++++++ .../stacks/views/StackResourcesPanel.kt | 213 ++++++ .../cfnlsp/stacks/views/StackStatusPoller.kt | 89 +++ .../stacks/views/StackViewCoordinator.kt | 111 ++++ .../stacks/views/StackViewPanelTabber.kt | 96 +++ .../views/StackViewToolWindowFactory.kt | 25 + .../stacks/views/StackViewWindowManager.kt | 188 ++++++ .../services/cfnlsp/stacks/views/Utils.kt | 39 ++ .../services/cfnlsp/ui/ChangeSetDiffPanel.kt | 532 +++++++++++++++ .../cfnlsp/ui/ResourceTypeDialogUtils.kt | 49 ++ .../cfnlsp/ui/ResourceTypeSelectionDialog.kt | 171 +++++ .../jetbrains/services/cfnlsp/ui/Utils.kt | 84 +++ .../cfnlsp/ui/ValidateAndDeployDialog.kt | 623 ++++++++++++++++++ .../cfnlsp/ui/ValidateAndDeployPersistence.kt | 37 ++ .../services/cfnlsp/ui/WrappingTextArea.kt | 21 + .../AbstractExplorerTreeToolWindow.kt | 5 +- .../explorer/AwsToolkitExplorerToolWindow.kt | 5 + .../core/explorer/ToolkitToolWindowTab.kt | 18 + .../toolkits/jetbrains/core/lsp/LspUtils.kt | 27 + .../jetbrains/core/lsp/NodeRuntimeResolver.kt | 58 ++ .../services/cfnlsp/CfnLspExtensionConfig.kt | 15 + .../services/cfnlsp/CfnLspServerProtocol.kt | 132 ++++ .../services/cfnlsp/CfnTelemetryPrompter.kt | 111 ++++ .../services/cfnlsp/protocol/AuthProtocol.kt | 13 + .../cfnlsp/protocol/ResourceProtocol.kt | 82 +++ .../cfnlsp/protocol/StackActionProtocol.kt | 300 +++++++++ .../services/cfnlsp/protocol/StackProtocol.kt | 162 +++++ .../services/cfnlsp/server/CfnLspException.kt | 20 + .../services/cfnlsp/server/CfnLspInstaller.kt | 234 +++++++ .../cfnlsp/server/CfnLspServerConfig.kt | 17 + .../cfnlsp/server/GitHubManifestAdapter.kt | 168 +++++ .../cfnlsp/server/LegacyLinuxDetector.kt | 124 ++++ .../services/cfnlsp/server/SemVer.kt | 101 +++ .../jetbrains/settings/CfnLspSettings.kt | 199 ++++++ .../settings/CfnLspSettingsConfigurable.kt | 181 +++++ .../cfnlsp/CfnLspIntroPromptStateTest.kt | 69 ++ .../documents/CfnDocumentManagerTest.kt | 79 +++ .../explorer/nodes/ResourcesNodeTest.kt | 100 +++ .../cfnlsp/explorer/nodes/StacksNodeTest.kt | 169 +++++ .../cfnlsp/resources/ResourceLoaderTest.kt | 239 +++++++ .../resources/ResourceStateServiceTest.kt | 116 ++++ .../resources/ResourceTypesManagerTest.kt | 217 ++++++ .../stacks/CfnOperationStatusServiceTest.kt | 130 ++++ .../stacks/ChangeSetDeletionWorkflowTest.kt | 84 +++ .../cfnlsp/stacks/ChangeSetsManagerTest.kt | 64 ++ .../cfnlsp/stacks/DeploymentWorkflowTest.kt | 99 +++ .../cfnlsp/stacks/StacksManagerTest.kt | 98 +++ .../cfnlsp/stacks/ValidationWorkflowTest.kt | 126 ++++ .../stacks/views/StackEventsPanelTest.kt | 408 ++++++++++++ .../stacks/views/StackOutputsPanelTest.kt | 257 ++++++++ .../stacks/views/StackOverviewPanelTest.kt | 159 +++++ .../views/StackPanelLayoutBuilderTest.kt | 141 ++++ .../stacks/views/StackResourcesPanelTest.kt | 286 ++++++++ .../stacks/views/StackViewCoordinatorTest.kt | 237 +++++++ .../services/cfnlsp/stacks/views/UtilsTest.kt | 156 +++++ .../services/cfnlsp/ui/ChangeSetDriftTest.kt | 182 +++++ .../jetbrains/services/cfnlsp/ui/UtilsTest.kt | 62 ++ .../jetbrains/core/lsp/LspUtilsTest.kt | 48 ++ .../cfnlsp/CfnTelemetryPromptStateTest.kt | 135 ++++ .../cfnlsp/server/CfnLspExceptionTest.kt | 50 ++ .../cfnlsp/server/CfnLspInstallerTest.kt | 208 ++++++ .../server/GitHubManifestAdapterTest.kt | 305 +++++++++ .../cfnlsp/server/LegacyLinuxDetectorTest.kt | 60 ++ .../services/cfnlsp/server/SemVerTest.kt | 136 ++++ .../jetbrains/settings/CfnLspSettingsTest.kt | 71 ++ 128 files changed, 14307 insertions(+), 17 deletions(-) create mode 100644 .changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json create mode 100644 .changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json create mode 100644 .changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json create mode 100644 .github/workflows/cfn-lsp-integ.yml create mode 100644 plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt create mode 100644 plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt create mode 100644 plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt create mode 100644 plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt diff --git a/.changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json b/.changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json new file mode 100644 index 00000000000..c5a6688067b --- /dev/null +++ b/.changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Added support for validating and deploying CloudFormation templates to new or existing stacks" +} \ No newline at end of file diff --git a/.changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json b/.changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json new file mode 100644 index 00000000000..c1cada1b8d7 --- /dev/null +++ b/.changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Added intelligent authoring support for CloudFormation templates." +} \ No newline at end of file diff --git a/.changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json b/.changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json new file mode 100644 index 00000000000..bb457a9507c --- /dev/null +++ b/.changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json @@ -0,0 +1,4 @@ +{ + "type" : "feature", + "description" : "Added support for viewing CloudFormation stack details such as resources and events" +} \ No newline at end of file diff --git a/.github/workflows/cfn-lsp-integ.yml b/.github/workflows/cfn-lsp-integ.yml new file mode 100644 index 00000000000..30e08bd5945 --- /dev/null +++ b/.github/workflows/cfn-lsp-integ.yml @@ -0,0 +1,36 @@ +name: CloudFormation LSP Integration Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main, feature/* ] + paths: + - '.github/workflows/cfn-lsp-integ.yml' + - 'plugins/toolkit/jetbrains-core/src*/**/cfnlsp/**' + - 'plugins/toolkit/jetbrains-core/it-253+/**/cfnlsp/**' + +jobs: + build: + name: ${{ matrix.os }} + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Support longpaths + if: ${{ matrix.os == 'windows-latest' }} + run: git config --system core.longpaths true + - uses: actions/checkout@v2 + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '21' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Run CloudFormation LSP Integration Tests + run: ./gradlew :plugin-toolkit:jetbrains-core:integrationTest --info --full-stacktrace --console plain --tests "software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspIntegrationTest" diff --git a/README.md b/README.md index 6ec8b4eb939..6cc2da25f59 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,28 @@ resource types supported by the plugin. * **Authentication** - Connect to AWS using static credentials, credential process, AWS Builder ID or AWS SSO. [Learn more about authentication options](https://docs.aws.amazon.com/console/toolkit-for-jetbrains/credentials) +### ![CloudFormation][cloudformation-icon] AWS CloudFormation + +Powered by the [CloudFormation Language Server](https://github.com/aws-cloudformation/cloudformation-languageserver), the toolkit provides rich CloudFormation +template authoring, stack and resource management capabilities. + +**Template Authoring** +* **Language Support** - Syntax validation, auto-completion, hover documentation, and go-to-definition for CloudFormation YAML/JSON templates +* **Static Validation** - Real-time diagnostics and error highlighting powered by [cfn-lint](https://github.com/aws-cloudformation/cfn-lint) and [CloudFormation Guard](https://github.com/aws-cloudformation/cloudformation-guard) + +**Stack Management** +* **CloudFormation Explorer** - Browse stacks, resources, and change sets in a dedicated tool window +* **Stack View** - Inspect stack details across Overview, Resources, Events, and Outputs tabs +* **Resource Explorer** - Search, add, and browse live resource types and their properties + +**Validate and Deploy** +* **Early Validation** - Create a change set to validate template for deployment readiness and receive diagnostics +* **Change Set Diff View** - Review resource-level and property-level changes before deploying, with side-by-side JSON diff +* **Live-Aware Drift Detection** - Inline drift annotations highlight resources that have changed outside of CloudFormation +* **Execute and Delete Change Sets** - Deploy or clean up change sets directly from the diff view or explorer tree + ### Services -#### ![CloudFormation][cloudformation-icon] AWS CloudFormation -* View events, resources, and outputs for your CloudFormation stacks #### ![CloudWatch Logs][cloudwatch-logs-icon] CloudWatch Logs * View and search your CloudWatch log streams #### ![AWS Lambda][lambda-icon] AWS Lambda diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts index 720e9913b72..44cb90e0d47 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-plugin.gradle.kts @@ -6,22 +6,22 @@ import software.aws.toolkits.gradle.intellij.IdeFlavor import software.aws.toolkits.gradle.intellij.ToolkitIntelliJExtension val intellijToolkit = project.extensions.create("intellijToolkit", ToolkitIntelliJExtension::class) -// TODO: how did this break? +// Use convention() so that toolkit-publish-root-conventions can override with runIdeVariant when { project.name.contains("jetbrains-rider") -> { - intellijToolkit.ideFlavor.set(IdeFlavor.RD) + intellijToolkit.ideFlavor.convention(IdeFlavor.RD) } project.name.contains("jetbrains-ultimate") -> { - intellijToolkit.ideFlavor.set(IdeFlavor.IU) + intellijToolkit.ideFlavor.convention(IdeFlavor.IU) } project.name.contains("jetbrains-gateway") -> { - intellijToolkit.ideFlavor.set(IdeFlavor.GW) + intellijToolkit.ideFlavor.convention(IdeFlavor.GW) } else -> { - intellijToolkit.ideFlavor.set(IdeFlavor.IC) + intellijToolkit.ideFlavor.convention(IdeFlavor.IC) } } diff --git a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts index 8d3a0261920..d91e6a1445f 100644 --- a/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-intellij-subplugin.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.tasks.PrepareSandboxTask import software.aws.toolkits.gradle.findFolders +import software.aws.toolkits.gradle.intellij.IdeFlavor import software.aws.toolkits.gradle.intellij.IdeVersions import software.aws.toolkits.gradle.intellij.toolkitIntelliJ @@ -97,7 +98,14 @@ dependencies { // annoying resolution issue that we don't want to bother fixing if (!project.name.contains("jetbrains-gateway")) { - val type = toolkitIntelliJ.ideFlavor.map { IntelliJPlatformType.fromCode(it.toString()) } + val type = toolkitIntelliJ.ideFlavor.map { flavor -> + // Starting with 2025.3, IntelliJ IDEA is unified (no separate Community edition) + if (version.get().startsWith("2025.3") && flavor == IdeFlavor.IC) { + IntelliJPlatformType.IntellijIdeaUltimate + } else { + IntelliJPlatformType.fromCode(flavor.toString()) + } + } create(type, version, useInstaller = false) } else { diff --git a/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts b/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts index 5a609c32d9a..8aed71ca141 100644 --- a/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/toolkit-publish-root-conventions.gradle.kts @@ -60,7 +60,7 @@ dependencies { } else { IdeFlavor.IC // Use Community for older versions } - ideFlavor.convention(IdeFlavor.values().firstOrNull { it.name == runIdeVariant.orNull } ?: defaultFlavor) + ideFlavor.set(IdeFlavor.values().firstOrNull { it.name == runIdeVariant.orNull } ?: defaultFlavor) } val (type, version) = if (runIdeVariant.isPresent) { val type = toolkitIntelliJ.ideFlavor.map { IntelliJPlatformType.fromCode(it.toString()) } diff --git a/gradle.properties b/gradle.properties index 5e0624a8cc8..c665d708134 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ toolkitVersion=3.106-SNAPSHOT publishToken= publishChannel= -ideProfileName=2025.2 +ideProfileName=2025.3 remoteRobotPort=8080 diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt index f2d74100e2c..cbd7299f162 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/ToolkitPlaces.kt @@ -6,6 +6,7 @@ package software.aws.toolkits.jetbrains object ToolkitPlaces { const val DEVTOOLS_TOOL_WINDOW = "DevToolsToolWindow" const val EXPLORER_TOOL_WINDOW = "ExplorerToolWindow" + const val CFN_TOOL_WINDOW = "CloudFormationToolWindow" const val EDITOR_PSI_REFERENCE = "Editor" const val DEV_TOOL_WINDOW = "DeveloperToolsWindow" const val CWQ_TOOL_WINDOW = "CodeWhispererQWindow" diff --git a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt index 11437defb65..ca36e886e5e 100644 --- a/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt +++ b/plugins/core/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/utils/rules/CodeInsightTestFixtureRule.kt @@ -30,6 +30,7 @@ import com.intellij.testFramework.writeChild import org.junit.runner.Description import org.mockito.Mockito import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import java.nio.file.Paths @@ -71,6 +72,20 @@ open class CodeInsightTestFixtureRule(protected val testDescription: LightProjec lazyFixture.ifSet { try { + // Shutdown LSP servers to prevent thread leaks + try { + val cfnLspProviderClass = Class.forName(CFN_LSP_PROVIDER_CLASS) + val lspServerManagerClass = Class.forName("com.intellij.platform.lsp.api.LspServerManager") + val getInstance = lspServerManagerClass.getMethod("getInstance", Project::class.java) + val lspServerManager = getInstance.invoke(null, fixture.project) + val stopServers = lspServerManagerClass.getMethod("stopServers", Class::class.java) + stopServers.invoke(lspServerManager, cfnLspProviderClass) + LOG.info { "Successfully stopped CFN LSP servers" } + } catch (_: ClassNotFoundException) { + LOG.info { "CFN LSP not available - skipping server shutdown" } + } catch (e: Exception) { + LOG.warn(e) { "Failed to stop CFN LSP servers" } + } fixture.tearDown() } catch (e: Exception) { LOG.warn(e) { "Exception during tear-down" } @@ -99,6 +114,9 @@ open class CodeInsightTestFixtureRule(protected val testDescription: LightProjec private companion object { val LOG = getLogger() + + // CfnLspServerSupportProvider must not be moved/renamed since we are hard-coding its class name + private const val CFN_LSP_PROVIDER_CLASS = "software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider" } } 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 a3cac32bd40..cafa95bd934 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -506,18 +506,123 @@ cloudformation.capabilities.iam.toolTipText=Allows templates that contain IAM re cloudformation.capabilities.named_iam=Named IAM cloudformation.capabilities.named_iam.toolTipText=Allows templates that contain IAM resources with custom names to be deployed cloudformation.capabilities.toolTipText=CloudFormation capabilities represent potentially dangerous actions that must be user acknowledged for CloudFormation to perform. +cloudformation.changeset.deletion.failed=Change Set Deletion failed for change set: {0}, in stack: {1} with reason: {2} +cloudformation.changeset.deletion.started=Deletion started for change set: {0}, in stack: {1} +cloudformation.changeset.deletion.success=Deletion completed successfully for change set: {0}, in stack: {1} +cloudformation.changeset.deletion.title=Change Set Deletion cloudformation.create_stack.failed=Failed to create stack {0}: {1} cloudformation.create_stack.failed_validation=Failed to create stack {0} due to validation error cloudformation.create_stack.timeout=Failed to create stack {0} in {1} seconds. View the latest status of the stack via the AWS Console. cloudformation.delete_stack.failed=Failed to delete stack {0}: {1} cloudformation.delete_stack.timeout=Failed to delete stack {0} in {1} seconds. View the latest status of the stack via the AWS Console. -cloudformation.execute_change_set.failed=Failed to execute change set against {0} -cloudformation.execute_change_set.success=Successfully executed change set against {0} -cloudformation.execute_change_set.success.title=Successfully executed change set +cloudformation.deploy.dialog.stack_name.invalid=Stack name must start with a letter and contain only alphanumeric characters and hyphens +cloudformation.deploy.dialog.stack_name.label=Stack name: +cloudformation.deploy.dialog.stack_name.placeholder=Enter the CloudFormation stack name +cloudformation.deploy.dialog.stack_name.required=Stack name is required +cloudformation.deploy.dialog.stack_name.too_long=Stack name must be 128 characters or less +cloudformation.deploy.dialog.template.invalid_extension=Invalid template file extension +cloudformation.deploy.dialog.template.label=Template: +cloudformation.deploy.dialog.template.not_found=Template file does not exist +cloudformation.deploy.dialog.template.required=Template path is required +cloudformation.deploy.dialog.title=Validate and Deploy +cloudformation.deployment.failed=Deployment failed for stack: {0} with reason: {1} +cloudformation.deployment.started=Deployment started for stack: {0} +cloudformation.deployment.success=Deployment completed successfully for stack: {0} +cloudformation.deployment.title=CloudFormation Deployment +cloudformation.explorer.refresh_all=Refresh +cloudformation.explorer.resources.add_type=Add Resource Type +cloudformation.explorer.resources.add_type_node=Add Resource Type +cloudformation.explorer.resources.clone=Clone Resource State +cloudformation.explorer.resources.clone.failed=Failed to clone {0} resource{1} +cloudformation.explorer.resources.clone.none=No resources were cloned +cloudformation.explorer.resources.clone.partial=Cloned {0} resource{1}, {2} failed +cloudformation.explorer.resources.clone.success=Successfully cloned {0} resource{1} +cloudformation.explorer.resources.copy_identifier=Copy Resource Identifier +cloudformation.explorer.resources.dialog.select=Select resource types to monitor: +cloudformation.explorer.resources.dialog.title=Add Resource Type +cloudformation.explorer.resources.import=Import Resource State +cloudformation.explorer.resources.import.failed=Failed to import {0} resource{1} +cloudformation.explorer.resources.import.none=No resources were imported +cloudformation.explorer.resources.import.partial=Imported {0} resource{1}, {2} failed +cloudformation.explorer.resources.import.success=Successfully imported {0} resource{1} +cloudformation.explorer.resources.load_more=Load More... +cloudformation.explorer.resources.loading=Loading {0} resources... +cloudformation.explorer.resources.no_resources=No resources found +cloudformation.explorer.resources.node=Resources +cloudformation.explorer.resources.refresh=Refresh Resources +cloudformation.explorer.resources.refresh_all_loaded=Refresh All Resources +cloudformation.explorer.resources.refresh_type=Refresh Resource List +cloudformation.explorer.resources.remove_type=Remove Resource Type from List +cloudformation.explorer.resources.search=Search Resource +cloudformation.explorer.resources.search.prompt=Enter resource identifier to search for in {0}: +cloudformation.explorer.resources.search.title=Search Resource +cloudformation.explorer.resources.stack_info=Get Stack Management Info +cloudformation.explorer.resources.stack_info.copy_arn=Copy Stack ARN +cloudformation.explorer.resources.stack_info.copy_name=Copy Stack Name +cloudformation.explorer.resources.stack_info.error=Failed to get stack management info +cloudformation.explorer.resources.stack_info.managed=Resource is managed by CloudFormation stack: {0} +cloudformation.explorer.resources.stack_info.not_managed=Resource is not managed by CloudFormation +cloudformation.explorer.resources.stack_info.title=Stack Management Info +cloudformation.explorer.sign_in=Sign in to get started +cloudformation.explorer.stacks.change_sets=Change Sets +cloudformation.explorer.stacks.load_more=load more... +cloudformation.explorer.stacks.load_more_stacks=Load More Stacks +cloudformation.explorer.stacks.node_name=Stacks +cloudformation.explorer.stacks.refresh=Refresh Stacks +cloudformation.explorer.tab.title=CloudFormation cloudformation.invalid_property=Property {0} has invalid value {1} cloudformation.key_not_found={0} not found on resource {1} +cloudformation.lsp.action.configure_node=Configure Node.js +cloudformation.lsp.error.download_failed=Failed to download CloudFormation LSP. Check your network connection. +cloudformation.lsp.error.extraction_failed=Failed to extract CloudFormation LSP. +cloudformation.lsp.error.hash_mismatch=Downloaded file integrity check failed. The file may be corrupted. +cloudformation.lsp.error.manifest_failed=Failed to fetch CloudFormation LSP manifest. Check your network connection. +cloudformation.lsp.error.no_compatible_version=No compatible CloudFormation LSP version found for your platform. +cloudformation.lsp.error.node_not_found=Node.js not found. Install or configure Node.js for CloudFormation Language Server. +cloudformation.lsp.error.title=CloudFormation Language Server +cloudformation.lsp.intro.prompt.action.dont_show=Don't show again +cloudformation.lsp.intro.prompt.action.explore=Explore CloudFormation +cloudformation.lsp.intro.prompt.message=Author templates with hover, code completion, and cfn-lint support, validate and deploy templates to stacks, and view stack information such as events and resources +cloudformation.lsp.intro.prompt.title=Introducing the new CloudFormation Panel +cloudformation.lsp.stack.view=CloudFormation Stack cloudformation.missing_property=Property {0} not found in {1} -cloudformation.service_name=AWS CloudFormation +cloudformation.settings.cfnguard.enable=Enable CloudFormation Guard validation +cloudformation.settings.cfnguard.enabledRulePacks=Enabled rule packs: +cloudformation.settings.cfnguard.group=CFN-Guard +cloudformation.settings.cfnguard.rulesFile=Custom rules file: +cloudformation.settings.cfnguard.rulesFile.comment=Path to custom cfn-guard rules file. If set, overrides the selected rule packs above. +cloudformation.settings.cfnguard.validateOnChange=Validate on document change +cloudformation.settings.cfnlint.appendRules=Append rules: +cloudformation.settings.cfnlint.appendRules.comment=Comma-separated additional rule directories +cloudformation.settings.cfnlint.customRules=Custom rules: +cloudformation.settings.cfnlint.customRules.comment=Comma-separated paths to custom rule files +cloudformation.settings.cfnlint.delayMs=Delay (ms): +cloudformation.settings.cfnlint.delayMs.comment=Delay before running cfn-lint after changes +cloudformation.settings.cfnlint.enable=Enable CloudFormation linting +cloudformation.settings.cfnlint.group=CFN-Lint +cloudformation.settings.cfnlint.ignoreChecks=Ignore checks: +cloudformation.settings.cfnlint.ignoreChecks.comment=Comma-separated rule IDs to ignore (e.g., W3002,E1001) +cloudformation.settings.cfnlint.includeChecks=Include checks: +cloudformation.settings.cfnlint.includeChecks.comment=Comma-separated rule IDs to include (e.g., I) +cloudformation.settings.cfnlint.includeExperimental=Include experimental rules +cloudformation.settings.cfnlint.lintOnChange=Lint on document change +cloudformation.settings.cfnlint.overrideSpec=Override spec: +cloudformation.settings.cfnlint.overrideSpec.comment=CloudFormation spec override file path +cloudformation.settings.cfnlint.path=cfn-lint path: +cloudformation.settings.cfnlint.path.comment=Path to cfn-lint executable (leave empty for bundled version) +cloudformation.settings.cfnlint.registrySchemas=Registry schemas: +cloudformation.settings.cfnlint.registrySchemas.comment=Comma-separated CloudFormation Registry schema paths +cloudformation.settings.completion.enable=Enable auto-completion +cloudformation.settings.completion.group=Completion +cloudformation.settings.completion.max=Maximum completions: +cloudformation.settings.general.group=General +cloudformation.settings.hover.enable=Enable hover information +cloudformation.settings.hover.group=Hover +cloudformation.settings.node.path=Node.js path: +cloudformation.settings.node.path.browse=Select Node.js Executable +cloudformation.settings.node.path.comment=Path to Node.js executable (leave empty for auto-detection) +cloudformation.settings.telemetry.enable=Enable anonymous telemetry +cloudformation.settings.title=CloudFormation cloudformation.stack.delete.action=Delete Stack... cloudformation.stack.filter.show_completed=Show Completed cloudformation.stack.logical_id=Logical ID @@ -537,12 +642,22 @@ cloudformation.stack.tab_labels.events=Events cloudformation.stack.tab_labels.outputs=Outputs cloudformation.stack.tab_labels.resources=Resources cloudformation.stack.type=Type -cloudformation.stack.view=View Stack Status +cloudformation.stack.view=View Stack Detail +cloudformation.telemetry.prompt.action.allow=Yes, allow +cloudformation.telemetry.prompt.action.learn_more=Learn more +cloudformation.telemetry.prompt.action.never=Never +cloudformation.telemetry.prompt.action.not_now=Not now +cloudformation.telemetry.prompt.message=Help us improve the AWS CloudFormation Language Server by sharing anonymous telemetry data with AWS. You can change this preference at any time in CloudFormation Settings. +cloudformation.telemetry.prompt.title=AWS CloudFormation Language Server cloudformation.template_index.missing_type=Resource type must not be null for indexing cloudformation.toolwindow.label=CloudFormation cloudformation.update_stack.failed=Failed to update stack {0}: {1} cloudformation.update_stack.failed_validation=Failed to update stack {0} due to validation error cloudformation.update_stack.timeout=Failed to update stack {0} in {1} seconds. View the latest status of the stack via the AWS Console. +cloudformation.validation.failed=Validation failed for stack: {0} with reason: {1} +cloudformation.validation.started=Validation started for stack: {0} +cloudformation.validation.success=Validation completed successfully for stack: {0} +cloudformation.validation.title=CloudFormation Validation cloudformation.yaml.invalid_root_type=Template does not start with a mapping: {0} cloudformation.yaml.too_many_documents=There should only be 1 YAML document per file: {0} cloudformation.yaml.too_many_files=Found {0} YAML files but only expected 1 diff --git a/plugins/toolkit/jetbrains-core/build.gradle.kts b/plugins/toolkit/jetbrains-core/build.gradle.kts index dc32bdd361c..b133b49ba98 100644 --- a/plugins/toolkit/jetbrains-core/build.gradle.kts +++ b/plugins/toolkit/jetbrains-core/build.gradle.kts @@ -195,6 +195,7 @@ dependencies { // TODO: remove Q dependency when split is fully done implementation(libs.bundles.jackson) implementation(libs.zjsonpatch) + implementation(libs.nimbus.jose.jwt) testFixturesApi(testFixtures(project(":plugin-core:jetbrains-community"))) } diff --git a/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt new file mode 100644 index 00000000000..0846eda60ef --- /dev/null +++ b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntegrationTest.kt @@ -0,0 +1,151 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.CompletionParams +import org.eclipse.lsp4j.DefinitionParams +import org.eclipse.lsp4j.DocumentSymbolParams +import org.eclipse.lsp4j.HoverParams +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * E2E integration test for CloudFormation LSP. + * Downloads the real language server and validates autocomplete, hover, + * go-to-definition, and document symbols via direct LSP protocol requests. + * + * Requires Node.js 18+ on PATH. Hard-fails if not found. + */ +class CfnLspIntegrationTest { + + private lateinit var lsp: CfnLspTestFixture + + @Before + fun setUp() { + val nodeAvailable = try { + val process = ProcessBuilder("node", "--version").start() + val exitCode = process.waitFor() + if (exitCode == 0) { + val version = process.inputStream.bufferedReader().readText().trim() + version.removePrefix("v").split(".").firstOrNull()?.toIntOrNull()?.let { it >= 18 } ?: false + } else { + false + } + } catch (_: Exception) { false } + + assertThat(nodeAvailable) + .withFailMessage("Node.js 18+ is required to run CloudFormation LSP integration tests") + .isTrue() + + val factory = IdeaTestFixtureFactory.getFixtureFactory() + val projectFixture = factory.createLightFixtureBuilder("CfnLspIntegTest").fixture + val codeFixture = factory.createCodeInsightFixture(projectFixture) + codeFixture.setUp() + lsp = CfnLspTestFixture(codeFixture) + } + + @After + fun tearDown() { + lsp.tearDown() + } + + @Test + fun `autocomplete provides CloudFormation top-level sections`() { + val file = lsp.openTemplate("top-level.yaml", "AWSTemplateFormatVersion: \"2010-09-09\"\n") + val labels = lspComplete(lsp.fileUri(file), Position(1, 0)) + assertThat(labels).anyMatch { it.contains("Resources") || it.contains("Parameters") || it.contains("Outputs") } + } + + @Test + fun `autocomplete provides resource types`() { + val file = lsp.openTemplate( + "resource-type.yaml", + "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n MyBucket:\n Type: " + ) + val labels = lspComplete(lsp.fileUri(file), Position(3, 10)) + assertThat(labels).anyMatch { it.startsWith("AWS::") } + } + + @Test + fun `autocomplete provides resource properties`() { + val file = lsp.openTemplate( + "resource-props.yaml", + "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n MyBucket:\n Type: AWS::S3::Bucket\n Properties:\n " + ) + val labels = lspComplete(lsp.fileUri(file), Position(5, 6)) + assertThat(labels).anyMatch { it.contains("BucketName") } + } + + @Test + fun `hover provides documentation for resource types`() { + val file = lsp.openTemplate( + "hover-resource.yaml", + "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n MyBucket:\n Type: AWS::S3::Bucket" + ) + val hover = lsp.request { it.textDocumentService.hover(HoverParams(TextDocumentIdentifier(lsp.fileUri(file)), Position(3, 15))) } + assertThat(hover).isNotNull() + } + + @Test + fun `go-to-definition navigates to parameter from Ref`() { + val file = lsp.openTemplate( + "definition-param.yaml", + """ + AWSTemplateFormatVersion: "2010-09-09" + Parameters: + MyParam: + Type: String + Resources: + MyBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref MyParam + """.trimIndent() + ) + val definition = lsp.request { + it.textDocumentService.definition( + DefinitionParams(TextDocumentIdentifier(lsp.fileUri(file)), Position(8, 25)) + ) + } + assertThat(definition).isNotNull() + } + + @Test + fun `document symbols provides template outline`() { + val file = lsp.openTemplate( + "symbols.yaml", + """ + AWSTemplateFormatVersion: "2010-09-09" + Parameters: + MyParam: + Type: String + Resources: + MyBucket: + Type: AWS::S3::Bucket + Outputs: + BucketName: + Value: !Ref MyBucket + """.trimIndent() + ) + val symbols = lsp.request { + it.textDocumentService.documentSymbol( + DocumentSymbolParams(TextDocumentIdentifier(lsp.fileUri(file))) + ) + } + assertThat(symbols).isNotNull() + assertThat(symbols).isNotEmpty() + } + + private fun lspComplete(uri: String, position: Position): List { + val result = lsp.request { it.textDocumentService.completion(CompletionParams(TextDocumentIdentifier(uri), position)) } + ?: return emptyList() + val items = if (result.isLeft) result.left else result.right?.items + return items?.map { it.label }.orEmpty() + } +} diff --git a/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt new file mode 100644 index 00000000000..b8fe3ffcc51 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/it-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspTestFixture.kt @@ -0,0 +1,142 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.application.runWriteActionAndWait +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.platform.lsp.api.LspServerState +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.runInEdtAndWait +import kotlinx.coroutines.runBlocking +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DocumentSymbolParams +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.services.LanguageServer +import software.aws.toolkits.core.utils.Waiters +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor +import java.time.Duration +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +private const val LSP_REQUEST_TIMEOUT_S = 30L + +/** + * Manages CloudFormation LSP server lifecycle and document operations for integration tests. + */ +internal class CfnLspTestFixture(private val fixture: CodeInsightTestFixture) { + + private var schemasReady = false + + fun tearDown() = fixture.tearDown() + + fun openTemplate(name: String, content: String): VirtualFile { + var file: VirtualFile? = null + runWriteActionAndWait { file = fixture.tempDirFixture.createFile(name, content) } + val vf = file ?: error("Failed to create test file") + runInEdtAndWait { fixture.openFileInEditor(vf) } + ensureRunning() + ensureSchemasLoaded() + + request { lsp -> + lsp.textDocumentService.didOpen( + DidOpenTextDocumentParams(TextDocumentItem(fileUri(vf), "yaml", 1, content)) + ) + CompletableFuture.completedFuture(Unit) + } + waitForDocumentProcessed(vf) + return vf + } + + fun fileUri(file: VirtualFile): String = file.toNioPath().toUri().toString() + + fun request(block: (LanguageServer) -> CompletableFuture): T { + val future = CompletableFuture() + runningServer().sendNotification { lsp -> + block(lsp).whenComplete { result, error -> + if (error != null) { + future.completeExceptionally(error) + } else { + future.complete(result) + } + } + } + return future.get(LSP_REQUEST_TIMEOUT_S, TimeUnit.SECONDS) + } + + private fun runningServer() = + LspServerManager.getInstance(fixture.project) + .getServersForProvider(CfnLspServerDescriptor.providerClass()) + .first { it.state == LspServerState.Running } + + private fun ensureRunning() { + val providerClass = CfnLspServerDescriptor.providerClass() + LspServerManager.getInstance(fixture.project) + .ensureServerStarted(providerClass, CfnLspServerDescriptor.getInstance(fixture.project)) + + runBlocking { + Waiters.waitUntil( + succeedOn = { it }, + maxDuration = Duration.ofSeconds(120), + ) { + val servers = LspServerManager.getInstance(fixture.project).getServersForProvider(providerClass) + servers.any { it.state == LspServerState.Running } + } + } ?: throw AssertionError("CloudFormation LSP server did not reach Running state") + } + + /** + * Calls aws/cfn/resources/types to confirm public schemas have been loaded. + */ + private fun ensureSchemasLoaded() { + if (schemasReady) return + + val protocolClass = Class.forName("software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspServerProtocol") + val listResourceTypes = protocolClass.getMethod("listResourceTypes") + + runBlocking { + Waiters.waitUntil( + succeedOn = { it }, + maxDuration = Duration.ofSeconds(60), + ) { + val future = CompletableFuture() + runningServer().sendNotification { lsp -> + if (protocolClass.isInstance(lsp)) { + @Suppress("UNCHECKED_CAST") + val resultFuture = listResourceTypes.invoke(lsp) as CompletableFuture + resultFuture.whenComplete { result, error -> + val types = result?.javaClass?.getMethod("getResourceTypes")?.invoke(result) as? List<*> + future.complete(error == null && types?.isNotEmpty() == true) + } + } else { + future.complete(false) + } + } + future.get(LSP_REQUEST_TIMEOUT_S, TimeUnit.SECONDS) + } + } ?: throw AssertionError("CloudFormation LSP schemas not loaded") + + schemasReady = true + } + + /** + * Requests document symbols to confirm the server has parsed the document after didOpen. + */ + private fun waitForDocumentProcessed(file: VirtualFile) { + runBlocking { + Waiters.waitUntil( + succeedOn = { it == true }, + maxDuration = Duration.ofSeconds(10), + ) { + val result = request { lsp -> + lsp.textDocumentService.documentSymbol( + DocumentSymbolParams(TextDocumentIdentifier(fileUri(file))) + ) + } + result?.isNotEmpty() + } + } ?: throw AssertionError("Document not processed by server") + } +} diff --git a/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml new file mode 100644 index 00000000000..b9466af0f00 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml index 39be6ec4e6d..59ba9bce660 100644 --- a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml +++ b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml @@ -34,6 +34,9 @@
  • Resource Explorer - View and manage AWS resources
  • +
  • + CloudFormation Support - Author templates with hover, code completion, and cfn-lint support, validate and deploy templates to stacks, and view stack information such as events and resources +
  • Run/Debug Local Lambda Functions - Locally test and step-through debug functions in a Lambda-like execution environment provided by the AWS SAM CLI. Supports Java, Python, Node.js, and .NET.
  • @@ -106,6 +109,7 @@ com.intellij.modules.java com.intellij.modules.python com.jetbrains.gateway + com.intellij.modules.lsp @@ -172,6 +176,7 @@ + @@ -180,6 +185,7 @@ + @@ -263,6 +269,7 @@ instance="software.aws.toolkits.jetbrains.settings.DynamicResourcesConfigurable" /> + @@ -277,6 +284,9 @@ + + + diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt new file mode 100644 index 00000000000..83beb980690 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnClientService.kt @@ -0,0 +1,210 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerManager +import org.eclipse.lsp4j.DidChangeConfigurationParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.TextDocumentItem +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ClearStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateDeploymentParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeleteChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeletionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeploymentStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetCapabilitiesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetParametersResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateArtifactsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStackResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTypesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider +import java.util.concurrent.CompletableFuture + +@Service(Service.Level.PROJECT) +internal class CfnClientService(private val project: Project) : Disposable { + private val lspServerProvider: () -> LspServer? = { + LspServerManager.getInstance(project) + .getServersForProvider(CfnLspServerSupportProvider::class.java) + .firstOrNull() + } + + fun updateIamCredentials(params: UpdateCredentialsParams): CompletableFuture = + sendRequest { it.updateIamCredentials(params) } + + fun listStacks(params: ListStacksParams): CompletableFuture = + sendRequest { it.listStacks(params) } + + fun listChangeSets(params: ListChangeSetsParams): CompletableFuture = + sendRequest { it.listChangeSets(params) } + + fun createValidation(params: CreateValidationParams): CompletableFuture = + sendRequest { it.createValidation(params) } + + fun getValidationStatus(params: Identifiable): CompletableFuture = + sendRequest { it.getValidationStatus(params) } + + fun describeValidationStatus(params: Identifiable): CompletableFuture = + sendRequest { it.describeValidationStatus(params) } + + fun describeChangeSet(params: DescribeChangeSetParams): CompletableFuture = + sendRequest { it.describeChangeSet(params) } + + fun deleteChangeSet(params: DeleteChangeSetParams): CompletableFuture = + sendRequest { it.deleteChangeSet(params) } + + fun getChangeSetDeletionStatus(params: Identifiable): CompletableFuture = + sendRequest { it.getChangeSetDeletionStatus(params) } + + fun describeChangeSetDeletionStatus(params: Identifiable): CompletableFuture = + sendRequest { it.describeChangeSetDeletionStatus(params) } + + fun createDeployment(params: CreateDeploymentParams): CompletableFuture = + sendRequest { it.createDeployment(params) } + + fun getDeploymentStatus(params: Identifiable): CompletableFuture = + sendRequest { it.getDeploymentStatus(params) } + + fun describeDeploymentStatus(params: Identifiable): CompletableFuture = + sendRequest { it.describeDeploymentStatus(params) } + + fun getParameters(uri: String): CompletableFuture = + sendRequest { it.getParameters(uri) } + + fun getCapabilities(uri: String): CompletableFuture = + sendRequest { it.getCapabilities(uri) } + + fun getTemplateResources(uri: String): CompletableFuture = + sendRequest { it.getTemplateResources(uri) } + + fun getTemplateArtifacts(uri: String): CompletableFuture = + sendRequest { it.getTemplateArtifacts(uri) } + + fun describeStack(params: DescribeStackParams): CompletableFuture = + sendRequest { it.describeStack(params) } + + fun listResourceTypes(): CompletableFuture = + sendRequest { it.listResourceTypes() } + + fun removeResourceType(resourceType: String): CompletableFuture = + sendRequest { it.removeResourceType(resourceType) } + + fun searchResource(params: SearchResourceParams): CompletableFuture = + sendRequest { it.searchResource(params) } + + fun listResources(params: ListResourcesParams): CompletableFuture = + sendRequest { it.listResources(params) } + + fun refreshResources(params: RefreshResourcesParams): CompletableFuture = + sendRequest { it.refreshResources(params) } + + fun getStackManagementInfo(resourceIdentifier: String): CompletableFuture = + sendRequest { it.getStackManagementInfo(resourceIdentifier) } + + fun getResourceState(params: ResourceStateParams): CompletableFuture = + sendRequest { it.getResourceState(params) } + + fun ensureDocumentOpen(file: VirtualFile, project: Project) { + val isOpenInEditor = FileEditorManager.getInstance(project).isFileOpen(file) + if (isOpenInEditor) return + + val server = lspServerProvider() ?: return + val descriptor = server.descriptor + val uri = descriptor.getFileUri(file) + val languageId = descriptor.getLanguageId(file) + val content = FileDocumentManager.getInstance().getDocument(file)?.text ?: return + + server.sendNotification { lsp -> + lsp.textDocumentService.didOpen( + DidOpenTextDocumentParams(TextDocumentItem(uri, languageId, 0, content)) + ) + } + } + + fun notifyConfigurationChanged() { + lspServerProvider()?.sendNotification { lsp -> + lsp.workspaceService.didChangeConfiguration(DidChangeConfigurationParams(emptyMap())) + } + } + + private fun sendRequest(request: (CfnLspServerProtocol) -> CompletableFuture): CompletableFuture { + val future = CompletableFuture() + val server = lspServerProvider() + if (server == null) { + future.complete(null) + return future + } + server.sendNotification { lsp -> + (lsp as? CfnLspServerProtocol)?.let { cfn -> + request(cfn).whenComplete { result, error -> + if (error != null) { + future.completeExceptionally(error) + } else { + future.complete(result) + } + } + } ?: future.complete(null) + } + return future + } + + fun getStackResources(params: GetStackResourcesParams): CompletableFuture = + sendRequest { it.getStackResources(params) } + + fun getStackEvents(params: GetStackEventsParams): CompletableFuture = + sendRequest { it.getStackEvents(params) } + + fun clearStackEvents(params: ClearStackEventsParams): CompletableFuture = + sendRequest { it.clearStackEvents(params) } + + override fun dispose() { + try { + LspServerManager.getInstance(project).stopServers(CfnLspServerSupportProvider::class.java) + } catch (e: Exception) { + LOG.warn(e) { "Failed to stop CFN LSP servers during disposal" } + } + } + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project): CfnClientService = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt new file mode 100644 index 00000000000..9150dbc0f72 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt @@ -0,0 +1,191 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerManager +import com.intellij.platform.lsp.api.LspServerManagerListener +import com.intellij.platform.lsp.api.LspServerState +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectEncrypter +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +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.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.jetbrains.settings.CfnLspSettingsChangeListener +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +/** + * Manages AWS credentials for the CloudFormation LSP server. + * + * Credentials are JWE-encrypted (A256GCM) before being sent to the server. + * The encryption key is generated per-session and passed in initialization options. + */ +@Service(Service.Level.PROJECT) +internal class CfnCredentialsService(private val project: Project) : Disposable { + private val encryptionKey: SecretKey = generateKey() + private var lastRegionId: String? = AwsConnectionManager.getInstance(project).selectedRegion?.id + + init { + val appBus = ApplicationManager.getApplication().messageBus.connect(this) + subscribeToCredentialChanges(appBus) + subscribeToSettingsChanges(appBus) + subscribeToServerStateChanges() + } + + val encryptionKeyBase64: String + get() = Base64.getEncoder().encodeToString(encryptionKey.encoded) + + fun sendCredentials(onRegionChange: Boolean = false) { + val credentials = resolveCredentials() ?: return + val encrypted = encrypt(credentials) + CfnClientService.getInstance(project).updateIamCredentials(UpdateCredentialsParams(encrypted, true)) + .thenAccept { result -> + LOG.info { "Credentials updated on LSP server: success=${result?.success}" } + if (onRegionChange && result?.success == true) { + val stacksManager = StacksManager.getInstance(project) + stacksManager.clear() + + val resourceLoader = ResourceLoader.getInstance(project) + resourceLoader.clear(null) + + stacksManager.reload() + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to update credentials on LSP server" } + null + } + } + + fun notifyConfigurationChanged() { + CfnClientService.getInstance(project).notifyConfigurationChanged() + LOG.info { "Sent didChangeConfiguration to LSP server" } + } + + private fun subscribeToCredentialChanges(appBus: com.intellij.util.messages.MessageBusConnection) { + appBus.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + sendCredentials() + } + } + ) + + project.messageBus.connect(this).subscribe( + AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, + object : ConnectionSettingsStateChangeNotifier { + override fun settingsStateChanged(newState: ConnectionState) { + if (newState is ConnectionState.ValidConnection) { + val newRegionId = newState.region.id + val regionChanged = lastRegionId != null && lastRegionId != newRegionId + lastRegionId = newRegionId + sendCredentials(onRegionChange = regionChanged) + } + } + } + ) + } + + private fun subscribeToSettingsChanges(appBus: com.intellij.util.messages.MessageBusConnection) { + appBus.subscribe( + CfnLspSettingsChangeListener.TOPIC, + CfnLspSettingsChangeListener { + notifyConfigurationChanged() + } + ) + } + + @Suppress("UnstableApiUsage") + private fun subscribeToServerStateChanges() { + LspServerManager.getInstance(project).addLspServerManagerListener( + object : LspServerManagerListener { + override fun serverStateChanged(lspServer: LspServer) { + if (lspServer.state == LspServerState.Running) { + LOG.info { "LSP server running, sending credentials" } + sendCredentials() + } + } + }, + this, + true + ) + } + + private fun resolveCredentials(): IamCredentials? { + val connectionManager = AwsConnectionManager.getInstance(project) + val region = connectionManager.selectedRegion ?: return null + + return try { + val credentialProvider = connectionManager.activeCredentialProvider + val awsCredentials = credentialProvider.resolveCredentials() + val sessionCredentials = awsCredentials as? AwsSessionCredentials + + IamCredentials( + profile = credentialProvider.shortName, + region = region.id, + accessKeyId = awsCredentials.accessKeyId(), + secretAccessKey = awsCredentials.secretAccessKey(), + sessionToken = sessionCredentials?.sessionToken() + ) + } catch (e: Exception) { + LOG.warn(e) { "Failed to resolve credentials" } + null + } + } + + private fun encrypt(credentials: IamCredentials): String { + val payload = """{"data":${jacksonObjectMapper().writeValueAsString(credentials)}}""" + val jwe = JWEObject( + JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM), + Payload(payload) + ) + jwe.encrypt(DirectEncrypter(encryptionKey)) + return jwe.serialize() + } + + override fun dispose() {} + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project): CfnCredentialsService = project.service() + + private fun generateKey(): SecretKey { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return SecretKeySpec(bytes, "AES") + } + } +} + +internal data class IamCredentials( + val profile: String, + val region: String, + val accessKeyId: String, + val secretAccessKey: String, + val sessionToken: String? = null, +) diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt new file mode 100644 index 00000000000..120b6e03993 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptState.kt @@ -0,0 +1,31 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +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 + +data class CfnLspIntroPromptStateData( + var hasResponded: Boolean = false, +) + +@Service +@State(name = "cfnLspIntroPromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnLspIntroPromptState : PersistentStateComponent { + private var state = CfnLspIntroPromptStateData() + + override fun getState(): CfnLspIntroPromptStateData = state + override fun loadState(state: CfnLspIntroPromptStateData) { this.state = state } + + fun hasResponded(): Boolean = state.hasResponded + fun setResponded() { state.hasResponded = true } + + companion object { + fun getInstance(): CfnLspIntroPromptState = service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt new file mode 100644 index 00000000000..3b01643a299 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt @@ -0,0 +1,59 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import software.aws.toolkits.jetbrains.core.explorer.AwsToolkitExplorerToolWindow +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class CfnLspIntroPrompter : ProjectActivity { + override suspend fun execute(project: Project) { + if (CfnLspIntroPromptState.getInstance().hasResponded()) return + + showPrompt(project) + } + + private fun showPrompt(project: Project) { + val notification = Notification( + CfnLspExtensionConfig.INTRO_NOTIFICATION_GROUP_ID, + message("cloudformation.lsp.intro.prompt.title"), + message("cloudformation.lsp.intro.prompt.message"), + NotificationType.INFORMATION + ) + + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.explore")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + openCloudFormationTab(project) + applyChoice() + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.dont_show")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice() + notification.expire() + } + }) + + notification.notify(project) + } + + private fun openCloudFormationTab(project: Project) { + val explorerToolWindow = AwsToolkitExplorerToolWindow.toolWindow(project) + explorerToolWindow.activate(null, true) + + val cfnTabTitle = message("cloudformation.explorer.tab.title") + AwsToolkitExplorerToolWindow.getInstance(project).selectTab(cfnTabTitle) + } + + private fun applyChoice() { + CfnLspIntroPromptState.getInstance().setResponded() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt new file mode 100644 index 00000000000..4c6736bf7dc --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt @@ -0,0 +1,19 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.StartupActivity +import com.intellij.platform.lsp.api.LspServerManager +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider + +internal class CfnLspStartupActivity : StartupActivity { + override fun runActivity(project: Project) { + LspServerManager.getInstance(project).ensureServerStarted( + CfnLspServerSupportProvider::class.java, + CfnLspServerDescriptor.getInstance(project) + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt new file mode 100644 index 00000000000..f8faae0c6a3 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManager.kt @@ -0,0 +1,41 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.documents + +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 + +internal data class DocumentMetadata( + val uri: String, + val fileName: String, + val ext: String, + val type: String, + val cfnType: String, + val languageId: String, + val version: Int, + val lineCount: Int, +) + +@Service(Service.Level.PROJECT) +internal class CfnDocumentManager { + private val documents = mutableListOf() + + fun getValidTemplates(): List = + documents.filter { it.cfnType == "template" } + + fun updateDocuments(newDocuments: List) { + LOG.info { "Updating documents to: $newDocuments" } + documents.clear() + documents.addAll(newDocuments) + } + + companion object { + private val LOG = getLogger() + + fun getInstance(project: Project): CfnDocumentManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt new file mode 100644 index 00000000000..5191b5e1821 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationRootNode.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourcesNode +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StacksNode +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +class CloudFormationRootNode(private val nodeProject: Project) : AbstractTreeNode(nodeProject, Any()) { + private val stacksManager by lazy { StacksManager.getInstance(nodeProject) } + private val changeSetsManager by lazy { ChangeSetsManager.getInstance(nodeProject) } + private val resourceTypesManager by lazy { ResourceTypesManager.getInstance(nodeProject) } + private val resourceLoader by lazy { ResourceLoader.getInstance(nodeProject) } + + override fun update(presentation: PresentationData) {} + + override fun getChildren(): Collection> = listOf( + StacksNode(nodeProject, stacksManager, changeSetsManager), + ResourcesNode(nodeProject, resourceTypesManager, resourceLoader) + ) +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt new file mode 100644 index 00000000000..5a0c83abd1e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindow.kt @@ -0,0 +1,112 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.ToolkitPlaces +import software.aws.toolkits.jetbrains.core.credentials.AwsConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ConnectionSettingsStateChangeNotifier +import software.aws.toolkits.jetbrains.core.credentials.ConnectionState +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.explorer.AbstractExplorerTreeToolWindow +import software.aws.toolkits.jetbrains.core.gettingstarted.requestCredentialsForExplorer +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.jetbrains.ui.CenteredInfoPanel +import software.aws.toolkits.resources.AwsToolkitBundle.message + +@Service(Service.Level.PROJECT) +internal class CloudFormationToolWindow(private val project: Project) : + AbstractExplorerTreeToolWindow( + CloudFormationTreeStructure(project), + initialTreeExpandDepth = 0 + ), + ConnectionSettingsStateChangeNotifier { + override val actionPlace = ToolkitPlaces.CFN_TOOL_WINDOW + + init { + val toolbarGroup = ActionManager.getInstance().getAction("aws.toolkit.cloudformation.toolbar") + toolbar = ActionManager.getInstance().createActionToolbar(actionPlace, toolbarGroup as ActionGroup, true).apply { + targetComponent = this@CloudFormationToolWindow + }.component + + StacksManager.getInstance(project).addListener { + runInEdt { redrawContent() } + } + ChangeSetsManager.getInstance(project).addListener { + runInEdt { redrawContent() } + } + ResourceLoader.getInstance(project).addListener { _, _ -> + runInEdt { redrawContent() } + } + ResourceTypesManager.getInstance(project).addListener { + runInEdt { redrawContent() } + } + subscribeToConnectionChanges() + updateContent() + } + + private fun subscribeToConnectionChanges() { + // Listen to connection state changes (for credential validation) + project.messageBus.connect(this).subscribe(AwsConnectionManager.CONNECTION_SETTINGS_STATE_CHANGED, this) + + // Listen to active connection changes + project.messageBus.connect(this).subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + runInEdt { updateContent() } + } + } + ) + } + + override fun settingsStateChanged(newState: ConnectionState) { + runInEdt { updateContent() } + } + + private fun updateContent() { + LOG.debug { "CloudFormationToolWindow updateContent() called" } + val connectionManager = AwsConnectionManager.getInstance(project) + when (val connectionState = connectionManager.connectionState) { + is ConnectionState.ValidConnection -> { + redrawContent() + } + is ConnectionState.ValidatingConnection -> { + LOG.debug { "Validating connection, showing validation message" } + setContent( + CenteredInfoPanel().apply { + addLine("Validating connection to AWS...") + } + ) + } + else -> { + LOG.debug { "No valid connection found (state: $connectionState), showing sign-in panel" } + setContent( + CenteredInfoPanel().apply { + addLine(message("cloudformation.explorer.sign_in")) + addDefaultActionButton(message("gettingstarted.explorer.new.setup")) { + requestCredentialsForExplorer(project) + } + } + ) + } + } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): CloudFormationToolWindow = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt new file mode 100644 index 00000000000..eb88e42ffa0 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationToolWindowTab.kt @@ -0,0 +1,16 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.core.explorer.ToolkitToolWindowTab +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.Component + +internal class CloudFormationToolWindowTab : ToolkitToolWindowTab { + override val tabId: String = message("cloudformation.explorer.tab.title") + + override fun createContent(project: Project): Component = + CloudFormationToolWindow.getInstance(project) +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt new file mode 100644 index 00000000000..a762ed4088c --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/CloudFormationTreeStructure.kt @@ -0,0 +1,15 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer + +import com.intellij.ide.projectView.TreeStructureProvider +import com.intellij.ide.util.treeView.AbstractTreeStructureBase +import com.intellij.openapi.project.Project + +class CloudFormationTreeStructure(project: Project) : AbstractTreeStructureBase(project) { + override fun getRootElement() = CloudFormationRootNode(myProject) + override fun getProviders(): List? = null + override fun commit() {} + override fun hasSomethingToCommit(): Boolean = false +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt new file mode 100644 index 00000000000..a7d19d08e99 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeleteChangeSetAction.kt @@ -0,0 +1,23 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ui.Messages +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ChangeSetNode +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetDeletionWorkflow + +internal class DeleteChangeSetAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + if (Messages.showYesNoDialog(project, "Delete change set '${node.changeSetName}'?", "Delete Change Set", null) == Messages.YES) { + ChangeSetDeletionWorkflow(project).delete(node.stackName, node.changeSetName) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt new file mode 100644 index 00000000000..cf5564bc552 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/DeployChangeSetAction.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ChangeSetNode +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.DeploymentWorkflow + +internal class DeployChangeSetAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + DeploymentWorkflow(project).deploy(node.stackName, node.changeSetName) + } + + override fun update(e: AnActionEvent) { + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() + e.presentation.isEnabled = node?.status == "CREATE_COMPLETE" + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt new file mode 100644 index 00000000000..ace7a875ad8 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/LoadMoreStacksAction.kt @@ -0,0 +1,16 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.resources.AwsToolkitBundle.message + +class LoadMoreStacksAction : AnAction(message("cloudformation.explorer.stacks.load_more_stacks")) { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + StacksManager.getInstance(project).loadMoreStacks() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt new file mode 100644 index 00000000000..ee99b3ca2e9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshAllAction.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +internal class RefreshAllAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + StacksManager.getInstance(project).reloadWithChangeSets() + val resourceLoader = ResourceLoader.getInstance(project) + resourceLoader.getLoadedResourceTypes().forEach { resourceLoader.refreshResources(it) } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt new file mode 100644 index 00000000000..75d44901470 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshChangeSetsAction.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackChangeSetsNode +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager + +internal class RefreshChangeSetsAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + ChangeSetsManager.getInstance(project).refreshChangeSets(node.stackName) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt new file mode 100644 index 00000000000..977241123e3 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/RefreshStacksAction.kt @@ -0,0 +1,15 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +class RefreshStacksAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + StacksManager.getInstance(project).reloadWithChangeSets() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt new file mode 100644 index 00000000000..c5fc734b33b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ResourceActions.kt @@ -0,0 +1,305 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +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.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceNode +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceTypeNode +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceStateService +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ResourceTypeDialogUtils +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.datatransfer.StringSelection + +class AddResourceTypeAction : AnAction( + message("cloudformation.explorer.resources.add_type"), + null, + AllIcons.General.Add +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + e.presentation.isEnabled = true + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceTypesManager = ResourceTypesManager.getInstance(project) + + // Always load types (in case region changed) + resourceTypesManager.loadAvailableTypes().thenRun { + LOG.info { "loading completed, showing dialog" } + ApplicationManager.getApplication().invokeLater { + showDialog(project, resourceTypesManager) + } + } + } + + private fun showDialog(project: Project, resourceTypesManager: ResourceTypesManager) { + ResourceTypeDialogUtils.showResourceTypeSelectionDialog(project, resourceTypesManager) + } + + companion object { + private val LOG = getLogger() + } +} + +class RemoveResourceTypeAction : AnAction( + message("cloudformation.explorer.resources.remove_type"), + null, + AllIcons.General.Remove +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + // Only enable if a ResourceTypeNode is selected + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceTypeNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceTypeNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceTypesManager = ResourceTypesManager.getInstance(project) + val resourceLoader = ResourceLoader.getInstance(project) + + // Get the selected ResourceTypeNode + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + // Remove the resource type + resourceTypesManager.removeResourceType(resourceTypeNode.resourceType) + resourceLoader.clear(resourceTypeNode.resourceType) + } +} + +class RefreshResourceTypeAction : AnAction( + message("cloudformation.explorer.resources.refresh_type"), + null, + AllIcons.Actions.Refresh +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + LOG.info { "RefreshResourceTypeAction triggered" } + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + // Get the selected nodes using the correct data key + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + + // Find ResourceTypeNode in the selection + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() + + if (resourceTypeNode != null) { + LOG.info { "Reloading resource type: ${resourceTypeNode.resourceType}" } + resourceLoader.refreshResources(resourceTypeNode.resourceType) + } else { + LOG.warn { "No ResourceTypeNode found in selection" } + } + } + companion object { + private val LOG = getLogger() + } +} + +class RefreshAllLoadedResourcesAction : AnAction( + message("cloudformation.explorer.resources.refresh_all_loaded"), + null, + AllIcons.Actions.Refresh +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + // Get all currently loaded resource types and reload them + val loadedTypes = resourceLoader.getLoadedResourceTypes() + loadedTypes.forEach { resourceType -> + resourceLoader.refreshResources(resourceType) + } + } +} + +class SearchResourceAction : AnAction( + message("cloudformation.explorer.resources.search"), + null, + AllIcons.Actions.Search +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + // Only enable if a ResourceTypeNode is selected + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceTypeNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceTypeNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + // Get the selected ResourceTypeNode + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + // Prompt user for resource identifier + val identifier = Messages.showInputDialog( + project, + message("cloudformation.explorer.resources.search.prompt", resourceTypeNode.resourceType), + message("cloudformation.explorer.resources.search.title"), + AllIcons.Actions.Search + ) ?: return + + if (identifier.isBlank()) return + + // Search for the resource + resourceLoader.searchResource(resourceTypeNode.resourceType, identifier.trim()) + } +} + +class ImportResourceStateAction : AnAction( + message("cloudformation.explorer.resources.import"), + null, + AllIcons.Actions.Download +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stateService = ResourceStateService.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNodes = selectedNodes?.filterIsInstance() ?: return + + stateService.importResourceState(resourceNodes) + } +} + +class CloneResourceStateAction : AnAction( + message("cloudformation.explorer.resources.clone"), + null, + AllIcons.Vcs.Clone +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.isNotEmpty() == true + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stateService = ResourceStateService.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNodes = selectedNodes?.filterIsInstance() ?: return + + stateService.cloneResourceState(resourceNodes) + } +} + +class CopyResourceIdentifierAction : AnAction( + message("cloudformation.explorer.resources.copy_identifier"), + null, + AllIcons.Actions.Copy +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.size == 1 + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + // Copy to clipboard + val selection = StringSelection(resourceNode.resourceIdentifier) + CopyPasteManager.getInstance().setContents(selection) + + // Show status message (similar to VS Code) + // Note: JetBrains doesn't have a direct equivalent to VS Code's status bar message + // but the copy operation will be visible in the clipboard + } +} + +class LoadMoreResourcesAction : AnAction( + message("cloudformation.explorer.resources.load_more"), + null, + AllIcons.General.Add +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() + + if (resourceTypeNode != null) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + val hasMore = resourceLoader.hasMore(resourceTypeNode.resourceType) + e.presentation.isVisible = hasMore + e.presentation.isEnabled = hasMore + } else { + e.presentation.isVisible = false + } + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val resourceLoader = ResourceLoader.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceTypeNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + resourceLoader.loadMoreResources(resourceTypeNode.resourceType) + } +} + +class GetStackManagementInfoAction : AnAction( + message("cloudformation.explorer.resources.stack_info"), + null, + AllIcons.Actions.Properties +) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val hasResourceNode = selectedNodes?.filterIsInstance()?.size == 1 + e.presentation.isEnabled = hasResourceNode + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stateService = ResourceStateService.getInstance(project) + + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + val resourceNode = selectedNodes?.filterIsInstance()?.firstOrNull() ?: return + + stateService.getStackManagementInfo(resourceNode) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt new file mode 100644 index 00000000000..05aff054485 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/actions/ViewChangeSetAction.kt @@ -0,0 +1,43 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.application.runInEdt +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ChangeSetNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ChangeSetDiffPanel +import software.aws.toolkits.jetbrains.utils.notifyError + +internal class ViewChangeSetAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val node = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + ?.filterIsInstance()?.firstOrNull() ?: return + + CfnClientService.getInstance(project) + .describeChangeSet(DescribeChangeSetParams(node.changeSetName, node.stackName)) + .thenAccept { result -> + if (result == null) { + notifyError("CloudFormation", "Failed to describe change set", project = project) + return@thenAccept + } + runInEdt { + ChangeSetDiffPanel.show( + project = project, + stackName = node.stackName, + changeSetName = node.changeSetName, + changes = result.changes.orEmpty(), + enableDeploy = result.status == "CREATE_COMPLETE", + status = result.status, + creationTime = result.creationTime, + description = result.description, + ) + } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt new file mode 100644 index 00000000000..0b13abe367c --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ChangeSetNodes.kt @@ -0,0 +1,134 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ChangeSetDiffPanel +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.event.MouseEvent + +internal class StackChangeSetsNode( + nodeProject: Project, + internal val stackName: String, + private val changeSetsManager: ChangeSetsManager, +) : AbstractTreeNode(nodeProject, "changesets-$stackName"), ActionGroupOnRightClick { + + override fun actionGroupName(): String = "aws.toolkit.cloudformation.changesets.actions" + + override fun update(presentation: PresentationData) { + val changeSets = changeSetsManager.get(stackName) + val hasMore = changeSetsManager.hasMore(stackName) + val countText = if (hasMore) "(${changeSets.size}+)" else "(${changeSets.size})" + presentation.addText(message("cloudformation.explorer.stacks.change_sets"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.addText(" $countText", SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + if (!changeSetsManager.isLoaded(stackName)) { + changeSetsManager.fetchChangeSets(stackName) + return emptyList() + } + + val changeSets = changeSetsManager.get(stackName) + if (changeSets.isEmpty()) { + return listOf(NoChangeSetsNode(project)) + } + + val nodes = changeSets.map { changeSet -> + ChangeSetNode(project, stackName, changeSet.changeSetName, changeSet.status) + } + + return if (changeSetsManager.hasMore(stackName)) { + nodes + LoadMoreChangeSetsNode(project, stackName, changeSetsManager) + } else { + nodes + } + } +} + +internal class NoChangeSetsNode(nodeProject: Project) : AbstractTreeNode(nodeProject, "no-changesets") { + override fun update(presentation: PresentationData) { + presentation.addText("No change sets found", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class LoadMoreChangeSetsNode( + nodeProject: Project, + private val stackName: String, + private val changeSetsManager: ChangeSetsManager, +) : AbstractActionTreeNode(nodeProject, "load-more-changesets-$stackName", AllIcons.General.Add) { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.stacks.load_more"), SimpleTextAttributes.LINK_ATTRIBUTES) + presentation.setIcon(AllIcons.General.Add) + } + + override fun onDoubleClick(event: MouseEvent) { + changeSetsManager.loadMoreChangeSets(stackName) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class ChangeSetNode( + nodeProject: Project, + val stackName: String, + val changeSetName: String, + internal val status: String, +) : AbstractActionTreeNode(nodeProject, changeSetName, null), ActionGroupOnRightClick { + + override fun actionGroupName(): String = "aws.toolkit.cloudformation.changeset.actions" + + override fun onDoubleClick(event: MouseEvent) { + val clientService = CfnClientService.getInstance(project) + clientService.describeChangeSet(DescribeChangeSetParams(changeSetName, stackName)) + .thenAccept { result -> + if (result != null) { + runInEdt { + ChangeSetDiffPanel.show( + project = project, + stackName = stackName, + changeSetName = changeSetName, + changes = result.changes.orEmpty(), + enableDeploy = result.status == "CREATE_COMPLETE", + status = result.status, + creationTime = result.creationTime, + description = result.description, + ) + } + } + } + } + + override fun update(presentation: PresentationData) { + presentation.addText(changeSetName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.addText(" [$status]", SimpleTextAttributes.GRAY_ATTRIBUTES) + presentation.setIcon(getStatusIcon()) + } + + private fun getStatusIcon() = when { + status.contains("COMPLETE") && !status.contains("FAILED") -> AllIcons.General.InspectionsOK + status.contains("FAILED") -> AllIcons.General.Error + status.contains("IN_PROGRESS") || status.contains("PENDING") -> AllIcons.Process.Step_1 + else -> null + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt new file mode 100644 index 00000000000..26bb4d3db15 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourceTypeNode.kt @@ -0,0 +1,156 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ResourceTypeDialogUtils +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.event.MouseEvent + +internal class ResourceTypeNode( + nodeProject: Project, + val resourceType: String, + private val resourceLoader: ResourceLoader, +) : AbstractTreeNode(nodeProject, resourceType), ActionGroupOnRightClick { + + override fun actionGroupName(): String = ACTION_GROUP_NAME + + override fun isAlwaysShowPlus(): Boolean = true + + override fun update(presentation: PresentationData) { + presentation.addText(resourceType, SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.setIcon(null) // Remove any default icon + + // Only show count if resources have been loaded (dropdown was expanded) + if (resourceLoader.isLoaded(resourceType)) { + val resources = resourceLoader.getCachedResources(resourceType) + if (resources != null) { + val hasMore = resourceLoader.hasMore(resourceType) + val countText = if (hasMore) " (${resources.size}+)" else " (${resources.size})" + presentation.addText(countText, SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + } + } + + override fun getChildren(): Collection> { + if (!resourceLoader.isLoaded(resourceType)) { + // Trigger loading when this node is expanded + resourceLoader.refreshResources(resourceType) + return listOf(LoadingResourcesNode(project, resourceType)) + } + + val resources = resourceLoader.getResourceIdentifiers(resourceType) + + if (resources.isEmpty()) { + return listOf(NoResourcesNode(project)) + } + + val nodes = resources.map { identifier -> + ResourceNode(project, resourceType, identifier) + } + + return if (resourceLoader.hasMore(resourceType)) { + nodes + LoadMoreResourcesNode(project, resourceType, resourceLoader) + } else { + nodes + } + } + + companion object { + private const val ACTION_GROUP_NAME = "aws.toolkit.cloudformation.resources.type.actions" + } +} + +internal class LoadingResourcesNode( + nodeProject: Project, + private val resourceType: String, +) : AbstractTreeNode(nodeProject, "loading") { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.resources.loading", resourceType), SimpleTextAttributes.GRAYED_ATTRIBUTES) + presentation.setIcon(AllIcons.Process.Step_1) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class NoResourcesNode( + nodeProject: Project, +) : AbstractTreeNode(nodeProject, "no-resources") { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.resources.no_resources"), SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class LoadMoreResourcesNode( + nodeProject: Project, + private val resourceType: String, + private val resourceLoader: ResourceLoader, +) : AbstractActionTreeNode(nodeProject, message("cloudformation.explorer.resources.load_more"), AllIcons.General.Add) { + + override fun onDoubleClick(event: MouseEvent) { + resourceLoader.loadMoreResources(resourceType) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class ResourceNode( + nodeProject: Project, + val resourceType: String, + val resourceIdentifier: String, +) : AbstractTreeNode(nodeProject, resourceIdentifier), ActionGroupOnRightClick { + override fun actionGroupName(): String = "aws.toolkit.cloudformation.resources.resource.actions" + + override fun update(presentation: PresentationData) { + presentation.addText(resourceIdentifier, SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.tooltip = "$resourceType: $resourceIdentifier" + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class AddResourceTypeNode( + nodeProject: Project, + private val resourceTypesManager: ResourceTypesManager, +) : AbstractActionTreeNode(nodeProject, message("cloudformation.explorer.resources.add_type_node"), AllIcons.General.Add) { + override fun onDoubleClick(event: MouseEvent) { + // Always load types (in case region changed) + resourceTypesManager.loadAvailableTypes().thenRun { + LOG.info { "loading completed, showing dialog" } + ApplicationManager.getApplication().invokeLater { + showDialog() + } + } + } + + private fun showDialog() { + ResourceTypeDialogUtils.showResourceTypeSelectionDialog(project, resourceTypesManager) + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt new file mode 100644 index 00000000000..3f7cff0bf70 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNode.kt @@ -0,0 +1,39 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class ResourcesNode( + nodeProject: Project, + private val resourceTypesManager: ResourceTypesManager, + private val resourceLoader: ResourceLoader, +) : AbstractTreeNode(nodeProject, "resources"), ActionGroupOnRightClick { + + override fun actionGroupName(): String = "aws.toolkit.cloudformation.resources.actions" + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.resources.node"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + val selectedTypes = resourceTypesManager.getSelectedResourceTypes() + return if (selectedTypes.isEmpty()) { + listOf(AddResourceTypeNode(project, resourceTypesManager)) + } else { + selectedTypes.map { typeName -> + ResourceTypeNode(project, typeName, resourceLoader) + } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt new file mode 100644 index 00000000000..7a3d5d84f06 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StackNodes.kt @@ -0,0 +1,126 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.project.Project +import com.intellij.ui.SimpleTextAttributes +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.AbstractActionTreeNode +import software.aws.toolkits.jetbrains.core.explorer.devToolsTab.nodes.ActionGroupOnRightClick +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class StacksNode( + nodeProject: Project, + private val stacksManager: StacksManager, + private val changeSetsManager: ChangeSetsManager, +) : AbstractTreeNode(nodeProject, "stacks"), ActionGroupOnRightClick { + + override fun actionGroupName(): String = + if (stacksManager.hasMore()) { + "aws.toolkit.cloudformation.stacks.actions.with_more" + } else { + "aws.toolkit.cloudformation.stacks.actions" + } + + override fun update(presentation: PresentationData) { + val count = if (stacksManager.isLoaded()) { + val size = stacksManager.get().size + if (stacksManager.hasMore()) "($size+)" else "($size)" + } else { + "" + } + presentation.addText(message("cloudformation.explorer.stacks.node_name"), SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.addText(" $count", SimpleTextAttributes.GRAY_ATTRIBUTES) + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + if (!stacksManager.isLoaded()) { + stacksManager.reload() + return emptyList() + } + + val stacks = stacksManager.get() + + if (stacks.isEmpty()) { + return listOf(NoStacksNode(project)) + } + + val nodes = stacks.map { stack -> + StackNode(project, stack, changeSetsManager) + } + + return if (stacksManager.hasMore()) { + nodes + LoadMoreStacksNode(project, stacksManager) + } else { + nodes + } + } +} + +internal class NoStacksNode(nodeProject: Project) : AbstractTreeNode(nodeProject, "no-stacks") { + override fun update(presentation: PresentationData) { + presentation.addText("No stacks found", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class LoadMoreStacksNode( + nodeProject: Project, + private val stacksManager: StacksManager, +) : AbstractActionTreeNode(nodeProject, "load-more-stacks", AllIcons.General.Add) { + + override fun update(presentation: PresentationData) { + presentation.addText(message("cloudformation.explorer.stacks.load_more"), SimpleTextAttributes.LINK_ATTRIBUTES) + presentation.setIcon(AllIcons.General.Add) + } + + override fun onDoubleClick(event: java.awt.event.MouseEvent) { + stacksManager.loadMoreStacks() + } + + override fun getChildren(): Collection> = emptyList() + override fun isAlwaysLeaf(): Boolean = true +} + +internal class StackNode( + nodeProject: Project, + val stack: StackSummary, + private val changeSetsManager: ChangeSetsManager, +) : AbstractTreeNode(nodeProject, stack), ActionGroupOnRightClick { + + override fun actionGroupName(): String = ACTION_GROUP_NAME + + override fun update(presentation: PresentationData) { + presentation.addText(stack.stackName ?: "Unknown Stack", SimpleTextAttributes.REGULAR_ATTRIBUTES) + presentation.setIcon(getStackIcon()) + presentation.tooltip = "${stack.stackName ?: "Unknown"} [${stack.stackStatus ?: "Unknown"}]" + } + + private fun getStackIcon() = when { + stack.stackStatus == null -> AllIcons.Nodes.Folder + stack.stackStatus.contains("COMPLETE") && !stack.stackStatus.contains("ROLLBACK") -> AllIcons.General.InspectionsOK + stack.stackStatus.contains("FAILED") || stack.stackStatus.contains("ROLLBACK") -> AllIcons.General.Error + stack.stackStatus.contains("PROGRESS") -> AllIcons.Process.Step_1 + else -> AllIcons.Nodes.Folder + } + + override fun isAlwaysShowPlus(): Boolean = true + + override fun getChildren(): Collection> { + val stackName = stack.stackName ?: return emptyList() + return listOf(StackChangeSetsNode(project, stackName, changeSetsManager)) + } + + companion object { + const val ACTION_GROUP_NAME = "aws.toolkit.cloudformation.stack.actions" + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt new file mode 100644 index 00000000000..58f3d89bbf5 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceCache.kt @@ -0,0 +1,28 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import java.util.concurrent.ConcurrentHashMap + +internal data class ResourceTypeData( + val resourceIdentifiers: List, + val nextToken: String? = null, + val loaded: Boolean = false, +) + +internal class ResourceCache { + private val resourcesByType = ConcurrentHashMap() + + fun get(resourceType: String): ResourceTypeData? = resourcesByType[resourceType] + + fun put(resourceType: String, data: ResourceTypeData) { + resourcesByType[resourceType] = data + } + + fun remove(resourceType: String) = resourcesByType.remove(resourceType) + + fun keys(): Set = resourcesByType.keys.toSet() + + fun clear() = resourcesByType.clear() +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt new file mode 100644 index 00000000000..23b688acab4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoader.kt @@ -0,0 +1,221 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.Disposable +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.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceRequest +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +typealias ResourcesChangeListener = (String, List) -> Unit + +@Service(Service.Level.PROJECT) +internal class ResourceLoader( + private val project: Project, +) : Disposable { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private val cache = ResourceCache() + private val listeners = CopyOnWriteArrayList() + private val loadingTypes = ConcurrentHashMap.newKeySet() + + fun addListener(listener: ResourcesChangeListener) { + listeners.add(listener) + } + + fun getResourceIdentifiers(resourceType: String): List = + cache.get(resourceType)?.resourceIdentifiers.orEmpty() + + fun getCachedResources(resourceType: String): List? = + cache.get(resourceType)?.resourceIdentifiers + + fun hasMore(resourceType: String): Boolean = + cache.get(resourceType)?.nextToken != null + + fun isLoaded(resourceType: String): Boolean = + cache.get(resourceType)?.loaded ?: false + + fun getLoadedResourceTypes(): Set = cache.keys() + + fun refreshResources(resourceType: String) { + loadResources(resourceType, loadMore = false, useRefresh = true) + } + + fun loadMoreResources(resourceType: String) { + val currentData = cache.get(resourceType) + if (currentData?.nextToken == null) return + loadResources(resourceType, loadMore = true) + } + + fun searchResource(resourceType: String, identifier: String): CompletableFuture { + LOG.info { "Searching for resource $identifier in type $resourceType" } + + val params = SearchResourceParams(resourceType, identifier) + return clientServiceProvider().searchResource(params) + .thenApply { result -> + if (result?.found == true) { + LOG.info { "Resource $identifier found in $resourceType" } + + if (result.resource != null) { + val currentData = cache.get(resourceType) + val existingResources = currentData?.resourceIdentifiers.orEmpty() + + if (!existingResources.contains(identifier)) { + val updatedResources = existingResources + identifier + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = updatedResources, + nextToken = currentData?.nextToken, + loaded = true + ) + ) + notifyListeners(resourceType, updatedResources) + } + } else { + if (cache.get(resourceType)?.loaded != true) { + refreshResources(resourceType) + } + } + true + } else { + LOG.info { "Resource $identifier not found in $resourceType" } + false + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to search for resource $identifier in $resourceType" } + false + } + } + + fun clear(resourceType: String?) { + if (resourceType != null) { + cache.remove(resourceType) + notifyListeners(resourceType, emptyList()) + } else { + val types = cache.keys() + cache.clear() + types.forEach { type -> + notifyListeners(type, emptyList()) + } + } + } + + private fun loadResources(resourceType: String, loadMore: Boolean, useRefresh: Boolean = false) { + if (!loadMore) { + loadingTypes.add(resourceType) + } + + LOG.info { "${if (useRefresh) "Refreshing" else "Loading"} resources for type $resourceType (loadMore=$loadMore)" } + + val currentData = cache.get(resourceType) + val nextToken = if (loadMore) currentData?.nextToken else null + + if (useRefresh) { + val params = RefreshResourcesParams(resources = listOf(ResourceRequest(resourceType))) + clientServiceProvider().refreshResources(params) + .thenAccept { result -> + handleResourceResult(resourceType, result?.resources, useRefresh) + } + .exceptionally { error -> + handleResourceError(resourceType, error, loadMore, useRefresh) + } + } else { + val params = ListResourcesParams(resources = listOf(ResourceRequest(resourceType, nextToken))) + clientServiceProvider().listResources(params) + .thenAccept { result -> + handleResourceResult(resourceType, result?.resources, useRefresh) + } + .exceptionally { error -> + handleResourceError(resourceType, error, loadMore, useRefresh) + } + } + } + + private fun handleResourceResult( + resourceType: String, + resources: List?, + useRefresh: Boolean, + ) { + loadingTypes.remove(resourceType) + + if (resources != null) { + val resourceSummary = resources.firstOrNull { it.typeName == resourceType } + if (resourceSummary != null) { + // LSP server returns cumulative results, use them directly + val allResources = resourceSummary.resourceIdentifiers + + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = allResources, + nextToken = resourceSummary.nextToken, + loaded = true + ) + ) + + notifyListeners(resourceType, allResources) + LOG.info { "${if (useRefresh) "Refreshed" else "Loaded"} ${resourceSummary.resourceIdentifiers.size} resources for $resourceType" } + } else { + LOG.info { "No resources found for $resourceType" } + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = emptyList(), + nextToken = null, + loaded = true + ) + ) + notifyListeners(resourceType, emptyList()) + } + } + } + + private fun handleResourceError( + resourceType: String, + error: Throwable, + loadMore: Boolean, + useRefresh: Boolean, + ): Nothing? { + loadingTypes.remove(resourceType) + LOG.warn(error) { "Failed to ${if (useRefresh) "refresh" else "load"} resources for $resourceType" } + if (!loadMore) { + cache.put( + resourceType, + ResourceTypeData( + resourceIdentifiers = emptyList(), + nextToken = null, + loaded = true + ) + ) + } + return null + } + + private fun notifyListeners(resourceType: String, resources: List) { + listeners.forEach { it(resourceType, resources) } + } + + override fun dispose() { + listeners.clear() + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ResourceLoader = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt new file mode 100644 index 00000000000..631a9e93802 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceNotificationService.kt @@ -0,0 +1,90 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStatePurpose +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.jetbrains.utils.notifyWarn +import software.aws.toolkits.resources.message +import java.awt.datatransfer.StringSelection + +internal class ResourceNotificationService(private val project: Project) { + fun showResultNotification(successCount: Int, failureCount: Int, purpose: ResourceStatePurpose) { + val actionKey = purpose.name.lowercase() + val titleKey = "cloudformation.explorer.resources.$actionKey" + val title = message(titleKey).removeSuffix(" Resource State") + val resourcePlural = if (successCount == 1 || failureCount == 1) "" else "s" + + when { + successCount > 0 && failureCount == 0 -> { + notifyInfo( + title, + message("cloudformation.explorer.resources.$actionKey.success", successCount, resourcePlural), + project + ) + } + successCount > 0 && failureCount > 0 -> { + val successPlural = if (successCount == 1) "" else "s" + notifyWarn( + title, + message("cloudformation.explorer.resources.$actionKey.partial", successCount, successPlural, failureCount), + project + ) + } + failureCount > 0 -> { + notifyError( + title, + message("cloudformation.explorer.resources.$actionKey.failed", failureCount, resourcePlural), + project + ) + } + else -> { + notifyInfo( + title, + message("cloudformation.explorer.resources.$actionKey.none"), + project + ) + } + } + } + + fun showStackManagementInfo(result: ResourceStackManagementResult) { + val messageText = if (result.managedByStack == true) { + message("cloudformation.explorer.resources.stack_info.managed", result.stackName ?: "Unknown") + } else { + message("cloudformation.explorer.resources.stack_info.not_managed") + } + + val actions = mutableListOf() + + if (result.managedByStack == true && result.stackName != null) { + actions.add(object : AnAction(message("cloudformation.explorer.resources.stack_info.copy_name")) { + override fun actionPerformed(e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(result.stackName)) + } + }) + + if (result.stackId != null) { + actions.add(object : AnAction(message("cloudformation.explorer.resources.stack_info.copy_arn")) { + override fun actionPerformed(e: AnActionEvent) { + CopyPasteManager.getInstance().setContents(StringSelection(result.stackId)) + } + }) + } + } + + notifyInfo( + message("cloudformation.explorer.resources.stack_info.title"), + messageText, + project, + actions + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt new file mode 100644 index 00000000000..9875f413824 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateEditor.kt @@ -0,0 +1,35 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor + +@Service(Service.Level.PROJECT) +internal class ResourceStateEditor(private val project: Project) { + fun insertAtCaret(text: String) { + val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return + ApplicationManager.getApplication().invokeLater { + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(editor.caretModel.offset, text) + } + } + } + + fun getActiveDocumentUri(): String? = + FileEditorManager.getInstance(project).selectedFiles.firstOrNull()?.let { + CfnLspServerDescriptor.getInstance(project).getFileUri(it) + } + + fun getActiveEditor() = FileEditorManager.getInstance(project).selectedTextEditor + + companion object { + fun getInstance(project: Project): ResourceStateEditor = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt new file mode 100644 index 00000000000..5ebfab4736d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateService.kt @@ -0,0 +1,148 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import org.eclipse.lsp4j.TextDocumentIdentifier +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.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceSelection +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStatePurpose +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.message + +@Service(Service.Level.PROJECT) +internal class ResourceStateService( + private val project: Project, +) { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + internal var editor = ResourceStateEditor.getInstance(project) + private val notificationService = ResourceNotificationService(project) + + fun importResourceState(resourceNodes: List) { + executeResourceStateOperation(resourceNodes, ResourceStatePurpose.IMPORT) + } + + fun cloneResourceState(resourceNodes: List) { + executeResourceStateOperation(resourceNodes, ResourceStatePurpose.CLONE) + } + + fun getStackManagementInfo(resourceNode: ResourceNode) { + clientServiceProvider().getStackManagementInfo(resourceNode.resourceIdentifier) + .thenAccept { result -> + LOG.info { "Stack management info result for ${resourceNode.resourceIdentifier}: $result" } + + ApplicationManager.getApplication().invokeLater { + if (result != null) { + notificationService.showStackManagementInfo(result) + } else { + LOG.warn { "Received null result from stack management info request" } + } + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to get stack management info for resource: ${resourceNode.resourceIdentifier}" } + ApplicationManager.getApplication().invokeLater { + notifyError( + message("cloudformation.explorer.resources.stack_info.error"), + error.message ?: "Unknown error" + ) + } + null + } + } + + private fun executeResourceStateOperation(resourceNodes: List, purpose: ResourceStatePurpose) { + if (editor.getActiveEditor() == null) { + LOG.warn { "No active editor found for resource state operation" } + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + "Open a CloudFormation template to author resource state", + project + ) + return + } + + val documentUri = editor.getActiveDocumentUri() + if (documentUri == null) { + LOG.warn { "No active file found for resource state operation" } + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + "No active file found", + project + ) + return + } + + val resourceSelections = resourceNodes.groupBy { it.resourceType }.map { (resourceType, nodes) -> + ResourceSelection(resourceType, nodes.map { it.resourceIdentifier }) + } + + LOG.info { "Executing ${purpose.name.lowercase()} operation for ${resourceNodes.size} resources" } + + val params = ResourceStateParams( + textDocument = TextDocumentIdentifier(documentUri), + resourceSelections = resourceSelections, + purpose = purpose.value + ) + + clientServiceProvider().getResourceState(params) + .thenAccept { result -> + if (result != null) { + result.warning?.let { warning -> + LOG.warn { "Warning: $warning" } + ApplicationManager.getApplication().invokeLater { + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + warning, + project + ) + } + } + + result.completionItem?.let { completionItem -> + val insertText = completionItem.insertText ?: completionItem.label + editor.insertAtCaret(insertText) + } + + val successCount = result.successfulImports.values.sumOf { it.size } + val failureCount = result.failedImports.values.sumOf { it.size } + + ApplicationManager.getApplication().invokeLater { + notificationService.showResultNotification(successCount, failureCount, purpose) + } + + if (result.successfulImports.isNotEmpty()) { + LOG.info { "Successfully processed: ${result.successfulImports}" } + } + if (result.failedImports.isNotEmpty()) { + LOG.warn { "Failed to process: ${result.failedImports}" } + } + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to execute ${purpose.name.lowercase()} operation" } + ApplicationManager.getApplication().invokeLater { + notifyError( + message("cloudformation.explorer.resources.${purpose.name.lowercase()}").removeSuffix(" Resource State"), + "Failed to ${purpose.name.lowercase()} resources: ${error.message ?: "Unknown error"}", + project + ) + } + null + } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ResourceStateService = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt new file mode 100644 index 00000000000..61d5fdc4d6d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManager.kt @@ -0,0 +1,102 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +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 +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.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import java.util.concurrent.CompletableFuture + +@Service(Service.Level.PROJECT) +@State(name = "cfnResourceTypes", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class ResourceTypesManager( + private val project: Project, +) : PersistentStateComponent { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private var state = ResourceTypesManagerState() + private var availableTypes: List = emptyList() + private var typesLoaded: Boolean = false + private val listeners = java.util.concurrent.CopyOnWriteArrayList() + + override fun getState(): ResourceTypesManagerState = state + + override fun loadState(state: ResourceTypesManagerState) { this.state = state } + + fun addListener(listener: ResourceTypesChangeListener) { + listeners.add(listener) + } + + fun getAvailableResourceTypes(): List = availableTypes.toList() + + fun areTypesLoaded(): Boolean = typesLoaded + + fun getSelectedResourceTypes(): Set = state.selectedTypes.toSet() + + fun addResourceType(typeName: String) { + if (typeName !in state.selectedTypes) { + state.selectedTypes.add(typeName) + notifyListeners() + } + } + + fun removeResourceType(typeName: String) { + if (typeName in state.selectedTypes) { + state.selectedTypes.remove(typeName) + notifyListeners() + + // Send async request to server to clear cache + LOG.info { "Removing resource type from LSP server: $typeName" } + clientServiceProvider().removeResourceType(typeName) + .thenAccept { + LOG.info { "Successfully removed resource type: $typeName" } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to remove resource type from LSP server: $typeName" } + null + } + } + } + + fun loadAvailableTypes(): CompletableFuture { + LOG.info { "Loading available resource types" } + + return clientServiceProvider().listResourceTypes() + .thenApply { result -> + if (result != null) { + LOG.info { "Loaded ${result.resourceTypes.size} resource types" } + availableTypes = result.resourceTypes + typesLoaded = true + } else { + LOG.warn { "Failed to load resource types - null result" } + } + } + .exceptionally { error -> + LOG.warn(error) { "Failed to load resource types" } + } + } + + private fun notifyListeners() { + listeners.forEach { it() } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ResourceTypesManager = project.service() + } +} + +internal data class ResourceTypesManagerState( + var selectedTypes: MutableSet = mutableSetOf(), +) + +typealias ResourceTypesChangeListener = () -> Unit diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt new file mode 100644 index 00000000000..e7393e6d843 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspClient.kt @@ -0,0 +1,23 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.openapi.project.Project +import com.intellij.platform.lsp.api.Lsp4jClient +import com.intellij.platform.lsp.api.LspServerNotificationsHandler +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.CfnDocumentManager +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.DocumentMetadata + +internal class CfnLspClient( + handler: LspServerNotificationsHandler, + private val project: Project, +) : Lsp4jClient(handler) { + + @JsonNotification("aws/documents/metadata") + fun onDocumentsMetadata(documents: Array) { + val documentManager = CfnDocumentManager.getInstance(project) + documentManager.updateDocuments(documents.toList()) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt new file mode 100644 index 00000000000..fa34dfc9694 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt @@ -0,0 +1,234 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationAction +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.Lsp4jClient +import com.intellij.platform.lsp.api.LspServerNotificationsHandler +import com.intellij.platform.lsp.api.LspServerSupportProvider +import com.intellij.platform.lsp.api.ProjectWideLspServerDescriptor +import com.intellij.psi.codeStyle.CodeStyleSettings +import org.eclipse.lsp4j.ConfigurationItem +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.services.LanguageServer +import org.jetbrains.annotations.TestOnly +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.lsp.NodeRuntimeResolver +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnCredentialsService +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspExtensionConfig +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnLspServerProtocol +import software.aws.toolkits.jetbrains.settings.AwsSettings +import software.aws.toolkits.jetbrains.settings.CfnLspSettings +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.nio.file.Path + +internal val CFN_SUPPORTED_EXTENSIONS = setOf("yaml", "yml", "json", "template", "cfn", "txt") + +private fun VirtualFile.isCfnTemplate(): Boolean = + extension?.lowercase() in CFN_SUPPORTED_EXTENSIONS + +// CfnLspServerSupportProvider must not be moved/renamed since we are hard-coding its class name +internal class CfnLspServerSupportProvider : LspServerSupportProvider { + override fun fileOpened( + project: Project, + file: VirtualFile, + serverStarter: LspServerSupportProvider.LspServerStarter, + ) { + if (file.isCfnTemplate()) { + serverStarter.ensureServerStarted(CfnLspServerDescriptor.getInstance(project)) + } + } +} + +class CfnLspServerDescriptor private constructor(project: Project) : + ProjectWideLspServerDescriptor(project, "AWS CloudFormation") { + + private val installer = CfnLspInstaller() + + override val lsp4jServerClass: Class = CfnLspServerProtocol::class.java + + override fun isSupportedFile(file: VirtualFile) = file.isCfnTemplate() + + override fun createLsp4jClient(handler: LspServerNotificationsHandler): Lsp4jClient = + CfnLspClient(CfnLspNotificationsHandler(handler), project) + + override fun createCommandLine(): GeneralCommandLine { + val serverPath = try { + installer.getServerPath() + } catch (e: CfnLspException) { + LOG.warn(e) { "Failed to get CloudFormation LSP server" } + notifyLspError(e) + throw e + } + + val nodePath = try { + resolveNodeRuntime() + } catch (e: CfnLspException) { + LOG.warn(e) { "Failed to resolve Node.js runtime" } + notifyNodeError() + throw e + } + + LOG.info { "Starting CloudFormation LSP: node=$nodePath, server=$serverPath" } + + return GeneralCommandLine(nodePath.toString(), serverPath.toString(), "--stdio") + .withWorkDirectory(serverPath.parent.toString()) + } + + private fun resolveNodeRuntime(): Path { + val settings = CfnLspSettings.getInstance() + + if (settings.nodeRuntimePath.isNotBlank()) { + return Path.of(settings.nodeRuntimePath) + } + + return NodeRuntimeResolver.resolve() + ?: throw CfnLspException( + message("cloudformation.lsp.error.node_not_found"), + CfnLspException.ErrorCode.NODE_NOT_FOUND + ) + } + + private fun notifyLspError(e: CfnLspException) { + val content = when (e.errorCode) { + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED -> message("cloudformation.lsp.error.manifest_failed") + CfnLspException.ErrorCode.NO_COMPATIBLE_VERSION -> message("cloudformation.lsp.error.no_compatible_version") + CfnLspException.ErrorCode.DOWNLOAD_FAILED -> message("cloudformation.lsp.error.download_failed") + CfnLspException.ErrorCode.EXTRACTION_FAILED -> message("cloudformation.lsp.error.extraction_failed") + CfnLspException.ErrorCode.NODE_NOT_FOUND -> message("cloudformation.lsp.error.node_not_found") + CfnLspException.ErrorCode.HASH_VERIFICATION_FAILED -> message("cloudformation.lsp.error.hash_mismatch") + } + + notifyError( + title = message("cloudformation.lsp.error.title"), + content = content, + project = project + ) + } + + private fun notifyNodeError() { + notifyError( + title = message("cloudformation.lsp.error.title"), + content = message("cloudformation.lsp.error.node_not_found"), + project = project, + notificationActions = listOf( + NotificationAction.createSimple("Download Node.js") { + BrowserUtil.browse("https://nodejs.org/en/download") + }, + NotificationAction.createSimple(message("cloudformation.lsp.action.configure_node")) { + ShowSettingsUtil.getInstance().showSettingsDialog(project, message("aws.settings.title")) + } + ) + ) + } + + override fun createInitializationOptions(): Any { + val settings = CfnLspSettings.getInstance() + val credentialsService = CfnCredentialsService.getInstance(project) + + return mapOf( + "handledSchemaProtocols" to listOf("file"), + "aws" to mapOf( + "clientInfo" to mapOf( + "extension" to mapOf( + "name" to CfnLspExtensionConfig.EXTENSION_NAME, + "version" to CfnLspExtensionConfig.EXTENSION_VERSION + ), + "clientId" to AwsSettings.getInstance().clientId.toString() + ), + "telemetryEnabled" to settings.isTelemetryEnabled, + "encryption" to mapOf( + "key" to credentialsService.encryptionKeyBase64, + "mode" to CfnLspExtensionConfig.ENCRYPTION_MODE + ) + ) + ) + } + + override fun getWorkspaceConfiguration(item: ConfigurationItem): Any? { + val section = item.section ?: return null + val settings = CfnLspSettings.getInstance() + + return when (section) { + "aws.cloudformation" -> buildCfnConfiguration(settings) + "editor" -> buildEditorConfiguration() + else -> null + } + } + + private fun buildCfnConfiguration(settings: CfnLspSettings): Map = mapOf( + "hover" to mapOf("enabled" to settings.isHoverEnabled), + "completion" to mapOf( + "enabled" to settings.isCompletionEnabled, + "maxCompletions" to settings.maxCompletions + ), + "diagnostics" to mapOf( + "cfnLint" to buildCfnLintConfiguration(settings), + "cfnGuard" to buildCfnGuardConfiguration(settings) + ) + ) + + private fun buildCfnLintConfiguration(settings: CfnLspSettings): Map = mapOf( + "enabled" to settings.isCfnLintEnabled, + "lintOnChange" to settings.cfnLintLintOnChange, + "delayMs" to settings.cfnLintDelayMs, + "includeExperimental" to settings.cfnLintIncludeExperimental, + "ignoreChecks" to settings.cfnLintIgnoreChecks.toStringList(), + "includeChecks" to settings.cfnLintIncludeChecks.toStringList(), + "customRules" to settings.cfnLintCustomRules.toStringList(), + "appendRules" to settings.cfnLintAppendRules.toStringList(), + "overrideSpec" to settings.cfnLintOverrideSpec.ifEmpty { null }, + "registrySchemas" to settings.cfnLintRegistrySchemas.toStringList() + ) + + private fun buildCfnGuardConfiguration(settings: CfnLspSettings): Map = mapOf( + "enabled" to settings.isCfnGuardEnabled, + "validateOnChange" to settings.cfnGuardValidateOnChange, + "enabledRulePacks" to settings.cfnGuardEnabledRulePacks.toStringList(), + "rulesFile" to settings.cfnGuardRulesFile.ifEmpty { null } + ) + + private fun buildEditorConfiguration(): Map { + val indentOptions = CodeStyleSettings.getDefaults().indentOptions + return mapOf( + "tabSize" to indentOptions.TAB_SIZE, + "insertSpaces" to !indentOptions.USE_TAB_CHARACTER, + "detectIndentation" to true + ) + } + + private fun String.toStringList(): List = + split(",").map { it.trim() }.filter { it.isNotEmpty() } + + companion object { + private val LOG = getLogger() + private val instances = mutableMapOf() + + fun getInstance(project: Project): CfnLspServerDescriptor = + instances.getOrPut(project) { CfnLspServerDescriptor(project) } + + @TestOnly + fun providerClass(): Class = CfnLspServerSupportProvider::class.java + } +} + +private class CfnLspNotificationsHandler( + private val delegate: LspServerNotificationsHandler, +) : LspServerNotificationsHandler by delegate { + override fun logMessage(params: MessageParams) { + LOG.info { "CloudFormation language server [${params.type}]: ${params.message}" } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt new file mode 100644 index 00000000000..83cc7635a56 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusService.kt @@ -0,0 +1,157 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.WindowManager +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal enum class OperationType { VALIDATION, DEPLOYMENT } + +internal data class OperationInfo( + val stackName: String, + val type: OperationType, + val changeSetName: String? = null, + val startTime: Instant = Instant.now(), + @Volatile var phase: StackActionPhase = StackActionPhase.VALIDATION_IN_PROGRESS, + @Volatile var released: Boolean = false, +) + +internal interface StatusBarHandle { + fun update(phase: StackActionPhase) + fun release() +} + +@Service(Service.Level.PROJECT) +internal class CfnOperationStatusService(private val project: Project) : Disposable { + + private val operations = ConcurrentHashMap() + private val nextId = AtomicInteger(0) + private val refCount = AtomicInteger(0) + private val scheduler = Executors.newSingleThreadScheduledExecutor() + + @Volatile private var disposeTask: ScheduledFuture<*>? = null + + fun acquire(stackName: String, type: OperationType, changeSetName: String? = null): StatusBarHandle { + synchronized(this) { + disposeTask?.cancel(false) + disposeTask = null + } + + val id = nextId.getAndIncrement() + val initialPhase = if (type == OperationType.VALIDATION) { + StackActionPhase.VALIDATION_IN_PROGRESS + } else { + StackActionPhase.DEPLOYMENT_IN_PROGRESS + } + operations[id] = OperationInfo(stackName, type, changeSetName, phase = initialPhase) + refCount.incrementAndGet() + updateWidget() + + return object : StatusBarHandle { + private var isReleased = false + + override fun update(phase: StackActionPhase) { + if (isReleased) return + operations[id]?.phase = phase + updateWidget() + } + + override fun release() { + if (isReleased) return + isReleased = true + operations[id]?.released = true + val remaining = refCount.decrementAndGet() + updateWidget() + if (remaining == 0) { + synchronized(this@CfnOperationStatusService) { + disposeTask = scheduler.schedule({ + operations.clear() + updateWidget() + }, DISPOSE_DELAY_MS, TimeUnit.MILLISECONDS) + } + } + } + } + } + + fun getActiveOperations(): List = + operations.values.filter { !it.released }.sortedByDescending { it.startTime } + + fun getAllOperations(): List = + operations.values.sortedByDescending { it.startTime } + + fun getStatusText(): String { + val unreleased = operations.values.filter { !it.released } + if (unreleased.isEmpty()) return "" + + if (unreleased.size == 1) { + val op = unreleased.first() + return when { + !op.phase.isTerminal() -> "${op.type.verb()} ${op.stackName}" + op.phase.isFailure() -> "${op.type.failedLabel()}: ${op.stackName}" + else -> "${op.type.doneLabel()} ${op.stackName}" + } + } + + val total = unreleased.size + return "$LABEL ($total)" + } + + private fun updateWidget() { + val statusBar = WindowManager.getInstance().getStatusBar(project) ?: return + statusBar.updateWidget(CfnStatusBarWidgetFactory.ID) + } + + override fun dispose() { + scheduler.shutdownNow() + operations.clear() + } + + companion object { + fun getInstance(project: Project): CfnOperationStatusService = project.service() + + private const val DISPOSE_DELAY_MS = 5000L + private const val LABEL = CfnStatusBarWidgetFactory.DISPLAY_NAME + + private fun OperationType.verb() = when (this) { + OperationType.VALIDATION -> "Validating" + OperationType.DEPLOYMENT -> "Deploying" + } + + private fun OperationType.doneLabel() = when (this) { + OperationType.VALIDATION -> "Validated" + OperationType.DEPLOYMENT -> "Deployed" + } + + private fun OperationType.failedLabel() = when (this) { + OperationType.VALIDATION -> "Validation Failed" + OperationType.DEPLOYMENT -> "Deployment Failed" + } + + internal fun StackActionPhase.isTerminal() = this in setOf( + StackActionPhase.VALIDATION_COMPLETE, + StackActionPhase.VALIDATION_FAILED, + StackActionPhase.DEPLOYMENT_COMPLETE, + StackActionPhase.DEPLOYMENT_FAILED, + StackActionPhase.DELETION_COMPLETE, + StackActionPhase.DELETION_FAILED, + ) + + internal fun StackActionPhase.isFailure() = this in setOf( + StackActionPhase.VALIDATION_FAILED, + StackActionPhase.DEPLOYMENT_FAILED, + StackActionPhase.DELETION_FAILED, + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt new file mode 100644 index 00000000000..067a9e73179 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnStatusBarWidgetFactory.kt @@ -0,0 +1,92 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.impl.status.widget.StatusBarEditorBasedWidgetFactory +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.SimpleListCellRenderer +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.CfnOperationStatusService.Companion.isFailure +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.CfnOperationStatusService.Companion.isTerminal +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.CfnStatusBarWidgetFactory.Companion.OPERATIONS_TITLE +import java.time.Duration +import java.time.Instant +import javax.swing.Icon + +internal class CfnStatusBarWidgetFactory : StatusBarEditorBasedWidgetFactory() { + override fun getId(): String = ID + override fun getDisplayName(): String = DISPLAY_NAME + override fun isAvailable(project: Project): Boolean = true + override fun createWidget(project: Project): StatusBarWidget = CfnStatusBarWidget(project) + override fun canBeEnabledOn(statusBar: StatusBar) = true + + companion object { + const val ID = "aws.toolkit.cloudformation.operation.status" + const val DISPLAY_NAME = "AWS CloudFormation" + const val OPERATIONS_TITLE = "AWS CloudFormation Operations" + } +} + +private class CfnStatusBarWidget( + private val project: Project, +) : StatusBarWidget, StatusBarWidget.MultipleTextValuesPresentation { + + override fun ID(): String = CfnStatusBarWidgetFactory.ID + + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + override fun getIcon(): Icon? { + val unreleased = CfnOperationStatusService.getInstance(project).getActiveOperations() + if (unreleased.isEmpty()) return null + val hasActive = unreleased.any { !it.phase.isTerminal() } + val hasFailed = unreleased.any { it.phase.isFailure() } + return when { + hasActive -> AnimatedIcon.Default() + hasFailed -> AllIcons.General.Error + else -> AllIcons.General.InspectionsOK + } + } + + override fun getSelectedValue(): String? { + val text = CfnOperationStatusService.getInstance(project).getStatusText() + return text.ifEmpty { null } + } + + override fun getTooltipText(): String = OPERATIONS_TITLE + + override fun getPopup(): com.intellij.openapi.ui.popup.JBPopup? { + val service = CfnOperationStatusService.getInstance(project) + val operations = service.getAllOperations() + if (operations.isEmpty()) return null + + return JBPopupFactory.getInstance() + .createPopupChooserBuilder(operations) + .setTitle(OPERATIONS_TITLE) + .setRenderer( + SimpleListCellRenderer.create { label, op: OperationInfo, _ -> + label.icon = when { + !op.phase.isTerminal() -> AnimatedIcon.Default() + op.phase.isFailure() -> AllIcons.General.Error + else -> AllIcons.General.InspectionsOK + } + val elapsed = formatElapsed(op.startTime) + val changeSetInfo = op.changeSetName?.let { " • $it" }.orEmpty() + label.text = "${op.stackName} — ${op.type.name.lowercase().replaceFirstChar { it.uppercase() }}$changeSetInfo ($elapsed)" + } + ) + .setItemChosenCallback {} + .createPopup() + } + + override fun dispose() {} +} + +private fun formatElapsed(startTime: Instant): String { + val seconds = Duration.between(startTime, Instant.now()).seconds + return if (seconds < 60) "${seconds}s ago" else "${seconds / 60}m ago" +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt new file mode 100644 index 00000000000..099cbb74889 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflow.kt @@ -0,0 +1,78 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeleteChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.util.UUID +import java.util.concurrent.CompletableFuture + +internal class ChangeSetDeletionWorkflow( + project: Project, + private val clientService: CfnClientService = CfnClientService.getInstance(project), +) : PollingWorkflow(project) { + + override val operationTitle = message("cloudformation.changeset.deletion.title") + + private lateinit var stackName: String + private lateinit var changeSetName: String + + override fun fetchStatus(id: String): CompletableFuture = + clientService.getChangeSetDeletionStatus(Identifiable(id)) + + override fun handleTerminalState(status: GetStackActionStatusResult, id: String): CompletableFuture?> = + when (status.phase) { + StackActionPhase.DELETION_COMPLETE -> { + notifyInfo(operationTitle, message("cloudformation.changeset.deletion.success", changeSetName, stackName), project = project) + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + runInEdt { + StackViewWindowManager.getInstance(project) + .getTabberByName(stackName)?.removeChangeSetTab() + } + CompletableFuture.completedFuture(PollResult.Success(true)) + } + + StackActionPhase.DELETION_FAILED -> { + clientService.describeChangeSetDeletionStatus(Identifiable(id)).thenApply { details -> + notifyError( + operationTitle, + message("cloudformation.changeset.deletion.failed", changeSetName, stackName, details?.failureReason ?: "Unknown"), + project = project + ) + PollResult.Failed(details?.failureReason) + } + } + + else -> CompletableFuture.completedFuture(null) + } + + fun delete(stackName: String, changeSetName: String): CompletableFuture> { + this.stackName = stackName + this.changeSetName = changeSetName + val id = UUID.randomUUID().toString() + + return clientService.deleteChangeSet(DeleteChangeSetParams(id, changeSetName, stackName)).thenCompose { result -> + if (result == null) { + notifyError( + operationTitle, + message("cloudformation.changeset.deletion.failed", changeSetName, stackName, "Failed to start deletion"), + project = project + ) + CompletableFuture.completedFuture(PollResult.Failed("Failed to start deletion")) + } else { + notifyInfo(operationTitle, message("cloudformation.changeset.deletion.started", changeSetName, stackName), project = project) + poll(id) + } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt new file mode 100644 index 00000000000..f92cbd75fb4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManager.kt @@ -0,0 +1,104 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +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.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ChangeSetInfo +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsParams +import java.util.concurrent.ConcurrentHashMap + +internal data class StackChangeSets( + val changeSets: List, + val nextToken: String? = null, +) + +@Service(Service.Level.PROJECT) +internal class ChangeSetsManager(private val project: Project) { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private val stackChangeSets = ConcurrentHashMap() + private val loadedStacks = ConcurrentHashMap.newKeySet() + private val listeners = mutableListOf<() -> Unit>() + + fun addListener(listener: () -> Unit) { + listeners.add(listener) + } + + fun isLoaded(stackName: String): Boolean = loadedStacks.contains(stackName) + + fun refreshChangeSets(stackName: String) { + loadedStacks.remove(stackName) + stackChangeSets.remove(stackName) + fetchChangeSets(stackName) + } + + fun fetchChangeSets(stackName: String) { + if (loadedStacks.contains(stackName)) return + + LOG.info { "Fetching change sets for $stackName" } + + clientServiceProvider().listChangeSets(ListChangeSetsParams(stackName)) + .thenAccept { result -> + if (result != null) { + LOG.info { "Loaded ${result.changeSets.size} change sets for $stackName" } + stackChangeSets[stackName] = StackChangeSets(result.changeSets, result.nextToken) + loadedStacks.add(stackName) + } else { + LOG.warn { "Received null result for change sets of $stackName" } + loadedStacks.add(stackName) + } + notifyListeners() + } + .exceptionally { error -> + LOG.warn(error) { "Failed to load change sets for $stackName" } + loadedStacks.add(stackName) + notifyListeners() + null + } + } + + fun loadMoreChangeSets(stackName: String) { + val current = stackChangeSets[stackName] ?: return + val nextToken = current.nextToken ?: return + + LOG.info { "Loading more change sets for $stackName" } + + clientServiceProvider().listChangeSets(ListChangeSetsParams(stackName, nextToken)) + .thenAccept { result -> + if (result != null) { + LOG.info { "Loaded ${result.changeSets.size} more change sets for $stackName" } + stackChangeSets[stackName] = StackChangeSets( + current.changeSets + result.changeSets, + result.nextToken + ) + } + notifyListeners() + } + .exceptionally { error -> + LOG.warn(error) { "Failed to load more change sets for $stackName" } + null + } + } + + fun get(stackName: String): List = + stackChangeSets[stackName]?.changeSets.orEmpty() + + fun hasMore(stackName: String): Boolean = + stackChangeSets[stackName]?.nextToken != null + + private fun notifyListeners() { + listeners.forEach { it() } + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): ChangeSetsManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt new file mode 100644 index 00000000000..1e21c1a799d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflow.kt @@ -0,0 +1,110 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.project.Project +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateDeploymentParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewTab +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.util.UUID +import java.util.concurrent.CompletableFuture + +internal class DeploymentWorkflow( + project: Project, + private val clientService: CfnClientService = CfnClientService.getInstance(project), + private val windowManager: StackViewWindowManager = StackViewWindowManager.getInstance(project), +) : PollingWorkflow(project) { + + override val operationTitle = message("cloudformation.deployment.title") + + private lateinit var stackName: String + + override fun fetchStatus(id: String): CompletableFuture = + clientService.getDeploymentStatus(Identifiable(id)) + + override fun handleTerminalState(status: GetStackActionStatusResult, id: String): CompletableFuture?> = + when (status.phase) { + StackActionPhase.DEPLOYMENT_COMPLETE -> { + StacksManager.getInstance(project).reload() + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + if (status.state == StackActionState.SUCCESSFUL) { + notifyInfo(operationTitle, message("cloudformation.deployment.success", stackName), project = project) + CompletableFuture.completedFuture(PollResult.Success(true)) + } else { + clientService.describeDeploymentStatus(Identifiable(id)).thenApply { details -> + notifyError( + operationTitle, + message("cloudformation.deployment.failed", stackName, details?.failureReason ?: "Unknown"), + project = project + ) + PollResult.Failed(details?.failureReason) + } + } + } + + StackActionPhase.DEPLOYMENT_FAILED, StackActionPhase.VALIDATION_FAILED -> { + StacksManager.getInstance(project).reload() + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + clientService.describeDeploymentStatus(Identifiable(id)).thenApply { details -> + notifyError(operationTitle, message("cloudformation.deployment.failed", stackName, details?.failureReason ?: "Unknown"), project = project) + PollResult.Failed(details?.failureReason) + } + } + + else -> CompletableFuture.completedFuture(null) + } + + fun deploy(stackName: String, changeSetName: String): CompletableFuture> { + this.stackName = stackName + val id = UUID.randomUUID().toString() + val statusHandle = CfnOperationStatusService.getInstance(project) + .acquire(stackName, OperationType.DEPLOYMENT, changeSetName) + + // Open stack view BEFORE starting deployment to avoid EDT blocking during deployment + val tabber = try { + windowManager.getOrOpenTabber(stackName) + } catch (e: Exception) { + LOG.error(e) { "Failed to open stack view for $stackName before deployment" } + null + } + + tabber?.switchToTab(StackViewTab.EVENTS) + + return clientService.createDeployment(CreateDeploymentParams(id, changeSetName, stackName)).thenCompose { result -> + if (result == null) { + notifyError(operationTitle, message("cloudformation.deployment.failed", stackName, "Failed to start deployment"), project = project) + statusHandle.update(StackActionPhase.DEPLOYMENT_FAILED) + statusHandle.release() + CompletableFuture.completedFuture(PollResult.Failed("Failed to start deployment")) + } else { + notifyInfo(operationTitle, message("cloudformation.deployment.started", stackName), project = project) + + tabber?.restartStatusPolling() + + poll(id).whenComplete { pollResult, _ -> + val phase = when (pollResult) { + is PollResult.Success -> StackActionPhase.DEPLOYMENT_COMPLETE + else -> StackActionPhase.DEPLOYMENT_FAILED + } + statusHandle.update(phase) + statusHandle.release() + } + } + } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt new file mode 100644 index 00000000000..04d40a63a66 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/LastValidationService.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams + +@Service(Service.Level.PROJECT) +internal class LastValidationService { + var lastParams: CreateValidationParams? = null + + companion object { + fun getInstance(project: Project): LastValidationService = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt new file mode 100644 index 00000000000..9f66e348c6e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/PollingWorkflow.kt @@ -0,0 +1,83 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.utils.notifyError +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal sealed class PollResult { + data class Success(val value: T) : PollResult() + data class Failed(val reason: String?) : PollResult() +} + +internal abstract class PollingWorkflow( + protected val project: Project, +) { + protected abstract fun fetchStatus(id: String): CompletableFuture + protected abstract fun handleTerminalState(status: GetStackActionStatusResult, id: String): CompletableFuture?> + protected abstract val operationTitle: String + + fun poll(id: String): CompletableFuture> { + val future = CompletableFuture>() + val scheduler = Executors.newSingleThreadScheduledExecutor() + val pollCount = AtomicInteger(0) + + scheduler.scheduleWithFixedDelay( + { + if (project.isDisposed) { + future.complete(PollResult.Failed("Project closed")) + scheduler.shutdown() + return@scheduleWithFixedDelay + } + + if (pollCount.incrementAndGet() > MAX_POLL_COUNT) { + notifyError(operationTitle, "Operation timed out", project = project) + future.complete(PollResult.Failed("Timed out after ${MAX_POLL_COUNT * POLL_INTERVAL_MS / 1000}s")) + scheduler.shutdown() + return@scheduleWithFixedDelay + } + + fetchStatus(id) + .thenCompose { status -> + if (status == null) { + notifyError(operationTitle, "Failed to get operation status", project = project) + future.complete(PollResult.Failed("Failed to get status")) + scheduler.shutdown() + return@thenCompose CompletableFuture.completedFuture(null) + } + + handleTerminalState(status, id) + } + .thenAccept { result -> + @Suppress("UNCHECKED_CAST") + val typedResult = result as? PollResult + if (typedResult != null) { + future.complete(typedResult) + scheduler.shutdown() + } + } + .exceptionally { error -> + notifyError(operationTitle, error.message ?: "Unknown error", project = project) + future.complete(PollResult.Failed(error.message)) + scheduler.shutdown() + null + } + }, + POLL_INTERVAL_MS, + POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS + ) + return future + } + + companion object { + private const val POLL_INTERVAL_MS = 1000L + private const val MAX_POLL_COUNT = 3600 // 1 hour at 1s intervals + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt new file mode 100644 index 00000000000..fbdfe44efd3 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/RerunValidateAndDeployAction.kt @@ -0,0 +1,31 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import software.aws.toolkits.jetbrains.utils.notifyError +import java.util.UUID + +internal class RerunValidateAndDeployAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val lastParams = LastValidationService.getInstance(project).lastParams + + if (lastParams == null) { + notifyError("CloudFormation", "No previous validation to rerun", project = project) + return + } + + ValidationWorkflow(project).validate(lastParams.copy(id = UUID.randomUUID().toString())) + } + + override fun update(e: AnActionEvent) { + val project = e.project ?: return + e.presentation.isEnabled = LastValidationService.getInstance(project).lastParams != null + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt new file mode 100644 index 00000000000..270648464cc --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManager.kt @@ -0,0 +1,127 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.Disposable +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.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackSummary +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo + +internal typealias StacksChangeListener = (List) -> Unit + +@Service(Service.Level.PROJECT) +internal class StacksManager(private val project: Project) : Disposable { + internal var clientServiceProvider: () -> CfnClientService = { CfnClientService.getInstance(project) } + + private var stacks: List = emptyList() + + private var nextToken: String? = null + + private var loaded = false + + private var loading = false + private val listeners = mutableListOf() + + fun addListener(listener: StacksChangeListener) { + listeners.add(listener) + } + + fun get(): List = stacks.toList() + + fun hasMore(): Boolean = nextToken != null + + fun isLoaded(): Boolean = loaded + + fun reload() { + if (loading) return + loadStacks(loadMore = false) + } + + fun reloadWithChangeSets() { + reload() + val changeSetsManager = ChangeSetsManager.getInstance(project) + stacks.forEach { stack -> + val stackName = stack.stackName ?: return@forEach + if (changeSetsManager.isLoaded(stackName)) { + changeSetsManager.refreshChangeSets(stackName) + } + } + } + + fun clear() { + stacks = emptyList() + nextToken = null + loaded = false + loading = false + notifyListeners() + } + + fun loadMoreStacks() { + if (nextToken == null || loading) return + loadStacks(loadMore = true) + } + + private fun loadStacks(loadMore: Boolean) { + loading = true + LOG.info { "Loading stacks (loadMore=$loadMore)" } + + val params = ListStacksParams( + statusToExclude = listOf("DELETE_COMPLETE"), + loadMore = loadMore + ) + + clientServiceProvider().listStacks(params) + .thenAccept { result -> + loading = false + if (result != null) { + LOG.info { "Loaded ${result.stacks.size} stacks" } + stacks = result.stacks + nextToken = result.nextToken + loaded = true + if (result.stacks.isEmpty() && !loadMore) { + notifyInfo("CloudFormation", "No stacks found in this region", project) + } + } else { + LOG.warn { "Received null result from listStacks" } + if (!loadMore) { + stacks = emptyList() + nextToken = null + loaded = true + } + } + notifyListeners() + } + .exceptionally { error -> + loading = false + LOG.warn(error) { "Failed to load stacks" } + notifyError("CloudFormation", "Failed to load stacks: ${error.message}", project) + if (!loadMore) { + stacks = emptyList() + nextToken = null + loaded = true + } + notifyListeners() + null + } + } + + private fun notifyListeners() { + listeners.forEach { it(stacks) } + } + + override fun dispose() {} + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): StacksManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt new file mode 100644 index 00000000000..224cc3714ba --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidateAndDeployAction.kt @@ -0,0 +1,176 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.vfs.VfsUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.CfnDocumentManager +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Parameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Tag +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateParameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateResource +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CFN_SUPPORTED_EXTENSIONS +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ValidateAndDeployWizard +import software.aws.toolkits.jetbrains.utils.notifyError +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit + +internal class ValidateAndDeployAction : AnAction() { + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val clientService = CfnClientService.getInstance(project) + + val selectedNode = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES)?.firstOrNull() + val templateFile = e.getData(CommonDataKeys.VIRTUAL_FILE) + ?: FileEditorManager.getInstance(project).selectedEditor?.file?.takeIf { + it.extension?.lowercase() in CFN_SUPPORTED_EXTENSIONS + } + + val prefilledTemplate = templateFile?.path + val prefilledStackName = (selectedNode as? StackNode)?.stack?.stackName + + val documentManager = CfnDocumentManager.getInstance(project) + + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Loading template configuration...", true) { + private var templateParams: List = emptyList() + private var detectedCaps: List = emptyList() + private var hasArtifacts = false + private var artifactError: String? = null + private var existingParams: List? = null + private var existingTags: List? = null + private var templateResources: List = emptyList() + private var isExistingStack = false + + override fun run(indicator: ProgressIndicator) { + if (templateFile != null) { + val descriptor = CfnLspServerDescriptor.getInstance(project) + val uri = descriptor.getFileUri(templateFile) + clientService.ensureDocumentOpen(templateFile, project) + + indicator.text = "Fetching template parameters..." + try { + templateParams = clientService.getParameters(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS)?.parameters.orEmpty() + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch template parameters" } + } + + indicator.text = "Analyzing capabilities..." + try { + detectedCaps = clientService.getCapabilities(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS)?.capabilities.orEmpty() + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch capabilities" } + } + + indicator.text = "Checking artifacts..." + try { + val artifactsResult = clientService.getTemplateArtifacts(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + val artifacts = artifactsResult?.artifacts.orEmpty() + hasArtifacts = artifacts.isNotEmpty() + val templateDir = templateFile.parent?.path.orEmpty() + for (artifact in artifacts) { + val artifactPath = if (artifact.filePath.startsWith("/")) { + artifact.filePath + } else { + "$templateDir/${artifact.filePath}" + } + if (!File(artifactPath).exists()) { + artifactError = artifact.filePath + return + } + } + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to check artifacts" } + } + + indicator.text = "Fetching template resources..." + try { + templateResources = clientService.getTemplateResources(uri).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS)?.resources.orEmpty() + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch template resources" } + } + } + + if (prefilledStackName != null) { + indicator.text = "Fetching stack details..." + try { + val stackResult = clientService.describeStack(DescribeStackParams(prefilledStackName)).get(LSP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + existingParams = stackResult?.stack?.parameters + existingTags = stackResult?.stack?.tags + isExistingStack = stackResult?.stack != null + } catch (ex: Exception) { + LOG.warn(ex) { "Failed to fetch stack details" } + } + } + } + + override fun onSuccess() { + if (artifactError != null) { + notifyError("CloudFormation", "Artifact path does not exist: $artifactError", project = project) + return + } + + val wizard = ValidateAndDeployWizard( + project = project, + documentManager = documentManager, + prefilledTemplatePath = prefilledTemplate, + prefilledStackName = prefilledStackName, + templateParameters = templateParams, + detectedCapabilities = detectedCaps, + existingParameters = existingParams, + existingTags = existingTags, + hasArtifacts = hasArtifacts, + templateResources = templateResources, + isExistingStack = isExistingStack, + ) + + if (!wizard.showAndGet()) return + + val settings = wizard.getSettings() + val templateVFile = VfsUtil.findFileByIoFile(File(settings.templatePath), true) ?: return + + val desc = CfnLspServerDescriptor.getInstance(project) + clientService.ensureDocumentOpen(templateVFile, project) + + val params = CreateValidationParams( + id = UUID.randomUUID().toString(), + uri = desc.getFileUri(templateVFile), + stackName = settings.stackName, + parameters = settings.parameters.ifEmpty { null }, + capabilities = settings.capabilities.ifEmpty { null }, + tags = settings.tags.ifEmpty { null }, + resourcesToImport = settings.resourcesToImport, + keepChangeSet = true, + onStackFailure = settings.onStackFailure, + includeNestedStacks = settings.includeNestedStacks, + importExistingResources = settings.importExistingResources, + deploymentMode = settings.deploymentMode, + s3Bucket = settings.s3Bucket, + s3Key = settings.s3Key, + ) + + ValidationWorkflow(project).validate(params) + } + }) + } + + companion object { + private val LOG = getLogger() + private const val LSP_TIMEOUT_SECONDS = 30L + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt new file mode 100644 index 00000000000..dc1948a2908 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflow.kt @@ -0,0 +1,187 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackChange +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ChangeSetDiffPanel +import software.aws.toolkits.jetbrains.utils.notifyError +import software.aws.toolkits.jetbrains.utils.notifyInfo +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +internal sealed class ValidationResult { + data class Success( + val changes: List, + val changeSetName: String, + val details: DescribeValidationStatusResult, + ) : ValidationResult() + + data class Failed(val reason: String?) : ValidationResult() +} + +internal class ValidationWorkflow( + private val project: Project, + private val clientService: CfnClientService = CfnClientService.getInstance(project), +) { + fun validate(params: CreateValidationParams): CompletableFuture { + LastValidationService.getInstance(project).lastParams = params + val statusHandle = CfnOperationStatusService.getInstance(project) + .acquire(params.stackName, OperationType.VALIDATION) + + notifyInfo( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.started", params.stackName), + project = project + ) + + return clientService.createValidation(params).thenCompose { result -> + if (result == null) { + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", params.stackName, "Failed to start validation"), + project = project + ) + statusHandle.update(StackActionPhase.VALIDATION_FAILED) + statusHandle.release() + CompletableFuture.completedFuture(ValidationResult.Failed("Failed to start validation")) + } else { + StacksManager.getInstance(project).reload() + ChangeSetsManager.getInstance(project).refreshChangeSets(params.stackName) + pollForCompletion(result.id, result.changeSetName, params.stackName, statusHandle) + } + }.exceptionally { error -> + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", params.stackName, error.cause?.message ?: error.message ?: "Unknown error"), + project = project + ) + statusHandle.update(StackActionPhase.VALIDATION_FAILED) + statusHandle.release() + ValidationResult.Failed(error.message) + } + } + + private fun pollForCompletion( + id: String, + changeSetName: String, + stackName: String, + statusHandle: StatusBarHandle, + ): CompletableFuture { + val future = CompletableFuture() + future.whenComplete { result, _ -> + val phase = when (result) { + is ValidationResult.Success -> StackActionPhase.VALIDATION_COMPLETE + else -> StackActionPhase.VALIDATION_FAILED + } + statusHandle.update(phase) + statusHandle.release() + } + val scheduler = Executors.newSingleThreadScheduledExecutor() + + scheduler.scheduleWithFixedDelay( + { + clientService.getValidationStatus(Identifiable(id)) + .thenAccept { status -> + if (status == null) { + future.complete(ValidationResult.Failed("Failed to get validation status")) + scheduler.shutdown() + return@thenAccept + } + + when (status.phase) { + StackActionPhase.VALIDATION_COMPLETE -> { + clientService.describeValidationStatus(Identifiable(id)) + .thenAccept { details -> + if (details == null) { + future.complete(ValidationResult.Failed("Failed to get validation details")) + } else if (status.state == StackActionState.SUCCESSFUL) { + notifyInfo( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.success", stackName), + project = project + ) + // Fetch full change set details (includes property-level changes) + clientService.describeChangeSet( + DescribeChangeSetParams(changeSetName, stackName) + ).thenAccept { changeSetResult -> + val fullChanges = changeSetResult?.changes ?: status.changes.orEmpty() + runInEdt { + ChangeSetDiffPanel.show( + project = project, + stackName = stackName, + changeSetName = changeSetName, + changes = fullChanges, + enableDeploy = true, + status = changeSetResult?.status, + creationTime = changeSetResult?.creationTime, + description = changeSetResult?.description, + ) + } + } + future.complete( + ValidationResult.Success( + changes = status.changes.orEmpty(), + changeSetName = changeSetName, + details = details + ) + ) + } else { + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", stackName, details.failureReason ?: "Unknown"), + project = project + ) + future.complete(ValidationResult.Failed(details.failureReason)) + } + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + scheduler.shutdown() + } + } + + StackActionPhase.VALIDATION_FAILED -> { + clientService.describeValidationStatus(Identifiable(id)) + .thenAccept { details -> + notifyError( + title = message("cloudformation.validation.title"), + content = message("cloudformation.validation.failed", stackName, details?.failureReason ?: "Unknown"), + project = project + ) + future.complete(ValidationResult.Failed(details?.failureReason)) + ChangeSetsManager.getInstance(project).refreshChangeSets(stackName) + scheduler.shutdown() + } + } + + else -> {} // continue polling + } + } + .exceptionally { error -> + future.complete(ValidationResult.Failed(error.message)) + scheduler.shutdown() + null + } + }, + POLL_INTERVAL_MS, + POLL_INTERVAL_MS, + TimeUnit.MILLISECONDS + ) + + return future + } + + companion object { + private const val POLL_INTERVAL_MS = 1000L + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt new file mode 100644 index 00000000000..b9e9bfc8d41 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt @@ -0,0 +1,53 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackNode +import software.aws.toolkits.resources.message + +internal class OpenStackViewAction : AnAction(), DumbAware { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + e.presentation.text = message("cloudformation.stack.view") + val stackNode = getStackNode(e) + e.presentation.isEnabledAndVisible = stackNode != null + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + val stackNode = getStackNode(e) ?: return + + if (stackNode.stack.stackName == null) { + LOG.error { "Stack name is null for stack node" } + return + } + if (stackNode.stack.stackId == null) { + LOG.error { "Stack ID is null for stack node" } + return + } + val stackName = stackNode.stack.stackName + val stackId = stackNode.stack.stackId + + StackViewWindowManager.getInstance(project) + .openStack(stackName, stackId) + } + + private fun getStackNode(e: AnActionEvent): StackNode? { + val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES) + return selectedNodes?.singleOrNull() as? StackNode + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt new file mode 100644 index 00000000000..e5243835c8a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanel.kt @@ -0,0 +1,226 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ClearStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import software.aws.toolkits.jetbrains.utils.notifyInfo +import java.util.concurrent.CompletableFuture +import javax.swing.JButton +import javax.swing.JComponent +import kotlin.math.ceil + +internal class StackEventsPanel( + private val project: Project, + coordinator: StackViewCoordinator, + stackArn: String, + private val stackName: String, +) : Disposable, StackPollingListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + private var allEvents: List = emptyList() + private var currentPage: Int = 0 + private var nextToken: String? = null + private val eventsPerPage = StackEventsTableComponents.EVENTS_PER_PAGE + private var isLoading = false // Prevents rapid-fire clicks from triggering multiple concurrent operations + + private val eventTable = StackPanelLayoutBuilder.createEventsTable { operationId -> + val consoleUrl = ConsoleUrlGenerator.generateOperationUrl(stackArn, operationId) + BrowserUtil.browse(consoleUrl) + } + + private val eventCountLabel = JBLabel("0 events").apply { + foreground = UIUtil.getContextHelpForeground() + } + + internal val pageLabel = JBLabel("Page 1 of 1") + internal val prevButton = JButton("Previous").apply { + addActionListener { loadPrevPage() } + isEnabled = false + } + internal val nextButton = JButton("Next").apply { + addActionListener { loadNextPage() } + isEnabled = false + } + internal val consoleLink = IconUtils.createConsoleLinkIcon { + ConsoleUrlGenerator.generateStackEventsUrl(stackArn) + }.apply { + isVisible = false // Start hidden until successful load + } + + val component: JComponent = StackPanelLayoutBuilder.createStackTablePanel( + stackName, + eventTable, + eventCountLabel, + consoleLink, + PaginationControls(pageLabel, prevButton, nextButton) + ) + + init { + disposables.add(coordinator.addPollingListener(stackArn, this)) + loadEvents() + } + + override fun onStackPolled() { + refresh() + } + + private fun loadEvents(): CompletableFuture { + if (isLoading) return CompletableFuture.completedFuture(null) + isLoading = true + + return cfnClientService.getStackEvents(GetStackEventsParams(stackName, nextToken)) + .thenApply { result: GetStackEventsResult? -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + result?.let { handleLoadResult(it) } + } + } + .exceptionally { error: Throwable -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + handleError("Failed to load events: ${error.message}") + } + } + } + + private fun refresh() { + if (isLoading) return + isLoading = true + + // Fetch only new events and prepend them (smart updates) + cfnClientService.getStackEvents(GetStackEventsParams(stackName, refresh = true)) + .whenComplete { result: GetStackEventsResult?, error: Throwable? -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + if (error != null) { + handleError("Failed to refresh events: ${error.message}") + } else { + result?.let { handleRefreshResult(it) } + } + } + } + } + + private fun handleLoadResult(result: GetStackEventsResult) { + consoleLink.isVisible = true + allEvents = if (nextToken == null) { + result.events + } else { + allEvents + result.events + } + nextToken = result.nextToken + renderEvents() + } + + private fun handleRefreshResult(result: GetStackEventsResult) { + if (result.gapDetected == true) { + cfnClientService.getStackEvents(GetStackEventsParams(stackName)) + .whenComplete { initialResult, error -> + ApplicationManager.getApplication().invokeLater { + if (error == null && initialResult != null) { + allEvents = initialResult.events + nextToken = initialResult.nextToken + currentPage = 0 + renderEvents("Event history reloaded due to high activity") + } + } + } + } else if (result.events.isNotEmpty()) { + allEvents = result.events + allEvents + currentPage = 0 + renderEvents() + } + } + + private fun loadNextPage() { + if (isLoading) return + + val totalPages = ceil(allEvents.size.toDouble() / eventsPerPage).toInt() + val nextPageIndex = currentPage + 1 + + if (nextPageIndex < totalPages) { + currentPage = nextPageIndex + renderEvents() + } else if (nextToken != null) { + loadEvents().thenRun { + ApplicationManager.getApplication().invokeLater { + currentPage = nextPageIndex + renderEvents() + } + } + } + } + + private fun loadPrevPage() { + if (isLoading) return + + if (currentPage > 0) { + currentPage-- + renderEvents() + } else { + refresh() + } + } + + private fun renderEvents(notification: String? = null) { + StackPanelLayoutBuilder.updateEventsTable(eventTable, allEvents) + StackPanelLayoutBuilder.updateEventsTablePage(eventTable, currentPage) + updateUIComponents() + + // Show notification if provided (gap detection warning) + notification?.let { + notifyInfo("CloudFormation Events", it, this.project) + } + } + + private fun updateUIComponents() { + val hasMore = nextToken != null + eventCountLabel.text = "${allEvents.size} events${if (hasMore) " loaded" else ""}" + + val totalPages = ceil(allEvents.size.toDouble() / eventsPerPage).toInt().coerceAtLeast(1) + pageLabel.text = "Page ${currentPage + 1} of $totalPages" + + prevButton.isEnabled = currentPage > 0 && !isLoading + + val isAtLastPage = currentPage >= totalPages - 1 + nextButton.text = if (isAtLastPage && hasMore) "Load More" else "Next" + nextButton.isEnabled = (!isAtLastPage || hasMore) && !isLoading + } + + private fun handleError(message: String) { + consoleLink.isVisible = false + allEvents = emptyList() + StackPanelLayoutBuilder.updateEventsTable(eventTable, allEvents, message) + updateUIComponents() + LOG.warn { message } + } + + override fun dispose() { + if (stackName.isNotEmpty()) { + cfnClientService.clearStackEvents(ClearStackEventsParams(stackName)) + } + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt new file mode 100644 index 00000000000..d639b3854c1 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsTableComponents.kt @@ -0,0 +1,292 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.icons.AllIcons +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import java.awt.Component +import java.awt.Font +import javax.swing.Icon +import javax.swing.JTable +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +internal object StackEventsTableComponents { + // Column indices for events table + const val ARROW_COLUMN = 0 + const val OPERATION_COLUMN = 1 + const val TIMESTAMP_COLUMN = 2 + const val STATUS_COLUMN = 3 + const val STATUS_REASON_COLUMN = 4 + + // Pagination constants + const val EVENTS_PER_PAGE = 50 +} + +// Data class to represent a table row (either parent group or child event) +internal data class EventTableRow( + val event: StackEvent, + val isParent: Boolean, + val isVisible: Boolean = true, + val parentIndex: Int? = null, + val childCount: Int = 0, +) + +internal class ExpandableEventsTableModel : AbstractTableModel() { + private var allEvents: List = emptyList() + private val displayRows: MutableList = mutableListOf() + val expandedGroups = mutableSetOf() // Make public for renderer access + private var hasHooks = false + private val baseColumnNames = arrayOf("", "Operation ID", "Timestamp", "Status", "Status Reason") + private val hookColumnName = "Hook Invocation" + private var currentPage = 0 + private val eventsPerPage = StackEventsTableComponents.EVENTS_PER_PAGE + + fun setEvents(events: List, errorMessage: String? = null) { + allEvents = events + hasHooks = false // Reset hook detection + + currentPage = 0 // Reset to first page + rebuildDisplayRows(errorMessage) + + if (events.isEmpty()) { + fireTableStructureChanged() + return + } + + // Group events by Operation ID + val grouped = events.groupBy { it.operationId ?: "No Operation" } + + // Auto-expand first group + if (grouped.isNotEmpty()) { + val firstOperationId = grouped.keys.first() + expandedGroups.add(firstOperationId) + rebuildDisplayRows() + } + + fireTableStructureChanged() // Use structure changed since column count may change + } + + fun setCurrentPage(page: Int) { + val oldHasHooks = hasHooks + currentPage = page + rebuildDisplayRows() + + // Fire structure changed if column count changed, otherwise just data changed + if (oldHasHooks != hasHooks) { + fireTableStructureChanged() + } else { + fireTableDataChanged() + } + } + + private fun rebuildDisplayRows(errorMessage: String? = null) { + displayRows.clear() + + if (errorMessage != null) { + val errorEvent = StackEvent( + operationId = "", + resourceType = "", + resourceStatus = "", + timestamp = "", + resourceStatusReason = errorMessage + ) + displayRows.add(EventTableRow(errorEvent, isParent = false)) + return + } + + if (allEvents.isEmpty()) { + return + } + + // Calculate pagination for events + val startIndex = currentPage * eventsPerPage + val endIndex = minOf(startIndex + eventsPerPage, allEvents.size) + val pageEvents = allEvents.subList(startIndex, endIndex) + + // Check if current page has hooks + hasHooks = pageEvents.any { it.hookType != null } + + // Group events by Operation ID + val grouped = pageEvents.groupBy { it.operationId ?: "No Operation" } + + grouped.forEach { (operationId, operationEvents) -> + if (operationEvents.size == 1 && operationId == "No Operation") { + // Single event without operation ID - add directly + displayRows.add(EventTableRow(operationEvents.first(), isParent = false)) + } else { + // Add parent row + val parentEvent = operationEvents.first() + displayRows.add( + EventTableRow( + event = parentEvent, + isParent = true, + childCount = operationEvents.size + ) + ) + + // Add child rows (visible only if expanded) + val isExpanded = expandedGroups.contains(operationId) + operationEvents.forEach { event -> + displayRows.add( + EventTableRow( + event = event, + isParent = false, + isVisible = isExpanded + ) + ) + } + } + } + } + + fun toggleExpansion(row: Int) { + val visibleRows = displayRows.filter { it.isVisible } + if (row >= visibleRows.size) return + + val clickedRow = visibleRows[row] + if (!clickedRow.isParent) return + + val operationId = clickedRow.event.operationId ?: return + + if (expandedGroups.contains(operationId)) { + expandedGroups.remove(operationId) + } else { + expandedGroups.add(operationId) + } + + rebuildDisplayRows() + fireTableDataChanged() // Only data changes, not structure + } + + override fun getRowCount(): Int = displayRows.count { it.isVisible } + override fun getColumnCount(): Int = if (hasHooks) baseColumnNames.size + 1 else baseColumnNames.size + override fun getColumnName(column: Int): String = + if (column < baseColumnNames.size) { + baseColumnNames[column] + } else { + hookColumnName + } + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any { + val visibleRows = displayRows.filter { it.isVisible } + if (rowIndex >= visibleRows.size) return "" + + val row = visibleRows[rowIndex] + val event = row.event + + return when (columnIndex) { + StackEventsTableComponents.ARROW_COLUMN -> if (row.isParent) { + val isExpanded = (row.event.operationId.orEmpty()) in expandedGroups + if (isExpanded) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + } else { + "" + } + StackEventsTableComponents.OPERATION_COLUMN -> if (row.isParent) { + row.event.operationId ?: "Unknown" + } else { + " ${row.event.operationId ?: "-"}" // Indent child rows + } + StackEventsTableComponents.TIMESTAMP_COLUMN -> event.timestamp.orEmpty() + StackEventsTableComponents.STATUS_COLUMN -> event.resourceStatus.orEmpty() + StackEventsTableComponents.STATUS_REASON_COLUMN -> event.resourceStatusReason?.takeIf { it.isNotEmpty() } ?: "-" + baseColumnNames.size -> if (hasHooks) { + if (event.hookType != null) { + "${event.hookType} (${event.hookStatus ?: "Unknown"})" + } else { + "-" + } + } else { + "" + } + else -> "" + } + } + + fun getRowAt(rowIndex: Int): EventTableRow? { + val visibleRows = displayRows.filter { it.isVisible } + return if (rowIndex < visibleRows.size) visibleRows[rowIndex] else null + } + + override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean = false +} + +internal class OperationCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val model = table.model as ExpandableEventsTableModel + val tableRow = model.getRowAt(row) + val component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + + if (tableRow?.isParent == true) { + val operationId = tableRow.event.operationId.orEmpty() + text = "$operationId" + font = font.deriveFont(Font.BOLD) + + // Remove click handler since table handles it + } else { + font = font.deriveFont(Font.PLAIN) + val operationId = tableRow?.event?.operationId + if (!isSelected && !operationId.isNullOrEmpty() && operationId != "-") { + foreground = JBColor.BLUE + } + } + + return component + } +} + +internal class EventsTableCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + + // Handle both icons and strings in arrow column + if (column == StackEventsTableComponents.ARROW_COLUMN) { + val label = JBLabel() + when (value) { + is Icon -> { + label.icon = value + label.text = null + label.horizontalAlignment = CENTER + } + is String -> { + label.text = value + label.icon = null + label.horizontalAlignment = CENTER + } + } + return label + } + + if (!isSelected && column == StackEventsTableComponents.STATUS_COLUMN) { // Status column + val status = value?.toString().orEmpty() + foreground = when { + status.contains("COMPLETE") && !status.contains("ROLLBACK") -> JBColor.GREEN + status.contains("FAILED") || status.contains("ROLLBACK") -> JBColor.RED + status.contains("PROGRESS") -> JBColor.ORANGE + else -> UIUtil.getTableForeground() + } + } else if (!isSelected) { + foreground = UIUtil.getTableForeground() + } + + return component + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt new file mode 100644 index 00000000000..d0ad55f3a68 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanel.kt @@ -0,0 +1,108 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackOutput +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import javax.swing.JComponent + +internal class StackOutputsPanel( + project: Project, + coordinator: StackViewCoordinator, + stackArn: String, + private val stackName: String, +) : Disposable, StackStatusListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + private var outputs: List = emptyList() + + // UI Components using StackPanelLayoutBuilder + internal val outputTable = StackPanelLayoutBuilder.createOutputsTable() + internal val outputCountLabel = JBLabel("0 outputs").apply { + foreground = UIUtil.getContextHelpForeground() + } + internal val consoleLink = IconUtils.createConsoleLinkIcon { + ConsoleUrlGenerator.generateStackOutputsUrl(stackArn) + }.apply { + isVisible = false // Start hidden until successful load + } + + val component: JComponent = StackPanelLayoutBuilder.createStackTablePanel( + stackName, + outputTable, + outputCountLabel, + consoleLink + ) + + init { + disposables.add(coordinator.addStatusListener(stackArn, this)) + } + + override fun onStackStatusUpdated() { + loadOutputs() + } + + private fun loadOutputs() { + cfnClientService.describeStack(DescribeStackParams(stackName)) + .thenApply { result -> result?.stack } + .whenComplete { result, error -> + ApplicationManager.getApplication().invokeLater { + if (error != null) { + handleError("Failed to load outputs for stack $stackName: ${error.message}") + } else { + result?.let { renderOutputs(it) } ?: renderEmpty() + } + } + } + } + + private fun renderOutputs(stack: StackDetail) { + outputs = stack.outputs.orEmpty() + consoleLink.isVisible = stack.stackId.isNotEmpty() + + StackPanelLayoutBuilder.updateOutputsTable(outputTable, outputs) + updateOutputCount(outputs.size) + } + + private fun renderEmpty() { + outputs = emptyList() + consoleLink.isVisible = false + StackPanelLayoutBuilder.updateOutputsTable(outputTable, outputs) + updateOutputCount(0) + } + + private fun handleError(message: String) { + outputs = emptyList() + consoleLink.isVisible = false + StackPanelLayoutBuilder.updateOutputsTable(outputTable, emptyList(), message) + updateOutputCount(0) + LOG.warn { message } + } + + private fun updateOutputCount(count: Int) { + outputCountLabel.text = "$count output${if (count != 1) "s" else ""}" + } + + override fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt new file mode 100644 index 00000000000..5b4f6a46886 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt @@ -0,0 +1,211 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTextArea +import com.intellij.util.ui.JBUI +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.WrappingTextArea +import java.awt.FlowLayout +import java.awt.Font +import java.awt.GridBagConstraints +import javax.swing.Box +import javax.swing.JComponent +import javax.swing.JPanel + +internal class StackOverviewPanel( + project: Project, + coordinator: StackViewCoordinator, + stackArn: String, + private val stackName: String, +) : Disposable, StackStatusListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + internal val consoleLink = IconUtils.createConsoleLinkIcon { + currentStackId?.let { stackId -> + ConsoleUrlGenerator.generateUrl(stackId) + } + } + + internal val stackNameValue = JBLabel("-") + internal var currentStackId: String? = null + internal val statusValue = JBLabel("Loading...") + internal val stackIdValue = WrappingTextArea("-") + internal val descriptionValue = WrappingTextArea("-") + internal val createdValue = JBLabel("-") + internal val lastUpdatedValue = JBLabel("-") + internal val statusReasonValue = WrappingTextArea("-") + + val component: JComponent = createPanel() + + init { + disposables.add(coordinator.addStatusListener(stackArn, this)) + setupStyling() + } + + private fun setupStyling() { + listOf(stackNameValue, statusValue, stackIdValue, descriptionValue, createdValue, lastUpdatedValue, statusReasonValue).forEach { label -> + label.font = label.font.deriveFont(Font.PLAIN) + } + + statusValue.border = JBUI.Borders.empty(STATUS_PADDING_VERTICAL, STATUS_PADDING_HORIZONTAL) + statusValue.horizontalAlignment = JBLabel.CENTER + } + + override fun onStackStatusUpdated() { + stackNameValue.text = stackName + renderEmpty() // Show loading state + loadStackDetails(stackName) + } + + private fun loadStackDetails(stackName: String) { + cfnClientService.describeStack(DescribeStackParams(stackName)) + .thenApply { result -> result?.stack } + .whenComplete { result, error -> + // LSP callbacks run on background threads, must switch to EDT for UI updates + ApplicationManager.getApplication().invokeLater { + if (error != null) { + LOG.warn { "Failed to load stack details for $stackName: ${error.message}" } + renderError("Failed to load stack: ${error.message}") + } else { + result?.let { + renderStack(it) + } ?: run { + LOG.warn { "No stack data received for $stackName" } + renderEmpty() + } + } + } + } + } + + private fun createPanel(): JPanel = StackPanelLayoutBuilder.createFormPanel().apply { + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.NORTHWEST + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + } + + var row = 0 + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Stack Name", createStackNamePanel()) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Status", statusValue, fillNone = true) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Stack ID", stackIdValue) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Description", descriptionValue) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Created", createdValue) + row = StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Last Updated", lastUpdatedValue) + StackPanelLayoutBuilder.addLabeledField(this, gbc, row, "Status Reason", statusReasonValue, isLast = true) + + StackPanelLayoutBuilder.addFiller(this, gbc, row) + } + + private fun createStackNamePanel(): JPanel = JBPanel>().apply { + layout = FlowLayout(FlowLayout.LEFT, 0, 0) + add(stackNameValue) + add(Box.createHorizontalStrut(StackPanelLayoutBuilder.ICON_SPACING)) + add(consoleLink) + } + + fun renderStack(stack: StackDetail) { + stackNameValue.text = stack.stackName + updateStatusDisplay(stack.stackStatus) + consoleLink.isVisible = stack.stackId.isNotEmpty() + + updateConditionalField(stackIdValue, stack.stackId.takeIf { it.isNotEmpty() }) + updateConditionalField(descriptionValue, stack.description?.takeIf { it.isNotEmpty() }) + updateConditionalField(createdValue, stack.creationTime?.let { StackDateFormatter.formatDate(it) }) + updateConditionalField(lastUpdatedValue, stack.lastUpdatedTime?.let { StackDateFormatter.formatDate(it) }) + updateConditionalField(statusReasonValue, stack.stackStatusReason?.takeIf { it.isNotEmpty() }) + + currentStackId = stack.stackId + } + + private fun renderEmpty() { + stackNameValue.text = "Select a stack to view details" + statusValue.text = "-" + stackIdValue.text = "-" + createdValue.text = "-" + statusReasonValue.text = "-" + resetStatusStyling() + consoleLink.isVisible = false + } + + private fun renderError(message: String) { + stackNameValue.text = stackName + statusValue.text = "Error" + stackIdValue.text = "-" + createdValue.text = "-" + statusReasonValue.text = message + resetStatusStyling() + consoleLink.isVisible = false + } + + private fun updateStatusDisplay(status: String) { + statusValue.text = status + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + + if (bgColor != null) { + statusValue.isOpaque = true + statusValue.background = bgColor + statusValue.foreground = fgColor + statusValue.font = statusValue.font.deriveFont(STATUS_FONT_SIZE) + } else { + resetStatusStyling() + } + } + + private fun resetStatusStyling() { + statusValue.isOpaque = false + statusValue.foreground = JBColor.foreground() + statusValue.font = statusValue.font.deriveFont(Font.PLAIN) + } + + private fun updateConditionalField(field: JComponent, value: String?) { + if (value != null) { + when (field) { + is JBLabel -> field.text = value + is JBTextArea -> field.text = value + } + setFieldVisibility(field, true) + } else { + setFieldVisibility(field, false) + } + } + + private fun setFieldVisibility(field: JComponent, visible: Boolean) { + field.isVisible = visible + val parent = field.parent + if (parent != null) { + val fieldIndex = parent.components.indexOf(field) + if (fieldIndex > 0) { + parent.components[fieldIndex - 1].isVisible = visible + } + } + } + + override fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + private const val STATUS_FONT_SIZE = 12.0f + private const val STATUS_PADDING_VERTICAL = 4 + private const val STATUS_PADDING_HORIZONTAL = 8 + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt new file mode 100644 index 00000000000..f2b074ef5f4 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt @@ -0,0 +1,267 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackOutput +import java.awt.Cursor +import java.awt.FlowLayout +import java.awt.Font +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.JTable +import javax.swing.ListSelectionModel +import javax.swing.table.DefaultTableModel + +internal data class PaginationControls( + val pageLabel: JComponent, + val prevButton: JButton, + val nextButton: JButton, +) + +internal object StackPanelLayoutBuilder { + internal const val ICON_SPACING = 4 + + private const val DEFAULT_PADDING = 20 + private const val FIELD_SPACING = 12 + + fun createTitleLabel(text: String): JBLabel = JBLabel(text).apply { + foreground = UIUtil.getContextHelpForeground() + font = font.deriveFont(Font.BOLD) + } + + fun createFormPanel(padding: Int = DEFAULT_PADDING): JBPanel> = JBPanel>(GridBagLayout()).apply { + border = JBUI.Borders.empty(padding) + } + + fun addLabeledField( + parent: JPanel, + gbc: GridBagConstraints, + startRow: Int, + labelText: String, + component: JComponent, + fillNone: Boolean = false, + isLast: Boolean = false, + ): Int { + // Add label + gbc.gridx = 0 + gbc.gridy = startRow + gbc.insets = JBUI.emptyInsets() + parent.add(createTitleLabel(labelText), gbc) + + // Add component + gbc.gridy = startRow + 1 + gbc.insets = if (isLast) JBUI.emptyInsets() else JBUI.insetsBottom(FIELD_SPACING) + if (fillNone) { + gbc.fill = GridBagConstraints.NONE + gbc.anchor = GridBagConstraints.WEST + } + parent.add(component, gbc) + + // Reset constraints + if (fillNone) { + gbc.fill = GridBagConstraints.HORIZONTAL + gbc.anchor = GridBagConstraints.NORTHWEST + } + + return startRow + 2 + } + + fun addFiller(parent: JPanel, gbc: GridBagConstraints, row: Int) { + gbc.gridy = row + 2 + gbc.weighty = 1.0 + gbc.fill = GridBagConstraints.BOTH + parent.add(JPanel(), gbc) + } + + fun createStackTablePanel( + stackName: String, + table: JBTable, + countLabel: JComponent? = null, + consoleLink: JComponent? = null, + paginationControls: PaginationControls? = null, + ): JComponent = panel { + row { + cell( + JBPanel>().apply { + layout = FlowLayout(FlowLayout.LEFT, ICON_SPACING, 0) + add(JBLabel("Stack: $stackName").apply { font = font.deriveFont(Font.BOLD) }) + consoleLink?.let { add(it) } + } + ) + countLabel?.let { cell(it) } + paginationControls?.let { pagination -> + cell( + JBPanel>().apply { + layout = FlowLayout(FlowLayout.RIGHT) + add(pagination.pageLabel) + add(pagination.prevButton) + add(pagination.nextButton) + } + ).align(AlignX.FILL) + } + } + row { + scrollCell(table).align(Align.FILL) + }.resizableRow() + } + + fun createOutputsTable(): JBTable = JBTable().apply { + setShowGrid(true) + autoResizeMode = JBTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + // Set initial empty state + model = object : DefaultTableModel( + arrayOf(arrayOf("No outputs found", "", "", "")), + arrayOf("Key", "Value", "Description", "Export Name") + ) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + } + + fun updateOutputsTable(table: JBTable, outputs: List, errorMessage: String? = null) { + val columnNames = arrayOf("Key", "Value", "Description", "Export Name") + val data = when { + errorMessage != null -> arrayOf(arrayOf(errorMessage, "", "", "")) + outputs.isEmpty() -> arrayOf(arrayOf("No outputs found", "", "", "")) + else -> outputs.map { output -> + arrayOf( + output.outputKey, + output.outputValue, + output.description.orEmpty(), + output.exportName.orEmpty() + ) + }.toTypedArray() + } + + table.model = object : DefaultTableModel(data, columnNames) { + override fun isCellEditable(row: Int, column: Int): Boolean = false + } + } + + fun createEventsTable(onOperationClick: ((String) -> Unit)? = null): JBTable { + val tableModel = ExpandableEventsTableModel() + return JBTable(tableModel).apply { + setShowGrid(true) + autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + selectionModel.selectionMode = ListSelectionModel.SINGLE_SELECTION + + // Arrow column renderer for icons and width + val arrowColumn = columnModel.getColumn(StackEventsTableComponents.ARROW_COLUMN) + arrowColumn.cellRenderer = EventsTableCellRenderer() + arrowColumn.preferredWidth = 30 + arrowColumn.maxWidth = 50 + arrowColumn.minWidth = 30 + + // Custom renderer for the second column (Operation) to show hyperlinks + columnModel.getColumn(StackEventsTableComponents.OPERATION_COLUMN).cellRenderer = OperationCellRenderer() + + // Status column renderer for colors + columnModel.getColumn(StackEventsTableComponents.STATUS_COLUMN).cellRenderer = EventsTableCellRenderer() + + // Click handler for expand/collapse (any column) and hyperlinks + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val row = rowAtPoint(e.point) + val col = columnAtPoint(e.point) + if (row >= 0) { + val model = model as ExpandableEventsTableModel + val tableRow = model.getRowAt(row) + + if (tableRow?.isParent == true) { + if (col == StackEventsTableComponents.OPERATION_COLUMN) { // Operation column - handle hyperlink first + val operationId = tableRow.event.operationId + if (!operationId.isNullOrEmpty()) { + onOperationClick?.invoke(operationId) + } + // Don't expand/collapse for hyperlink clicks + } else { + // Any other column - expand/collapse + model.toggleExpansion(row) + } + } else if (col == StackEventsTableComponents.OPERATION_COLUMN) { // Child row operation column + val operationId = tableRow?.event?.operationId + if (!operationId.isNullOrEmpty() && operationId != "-") { + onOperationClick?.invoke(operationId) + } + } + } + } + }) + + // Mouse motion listener for cursor changes on hyperlinks + addMouseMotionListener(object : MouseAdapter() { + override fun mouseMoved(e: MouseEvent) { + val row = rowAtPoint(e.point) + val col = columnAtPoint(e.point) + if (row >= 0 && col == StackEventsTableComponents.OPERATION_COLUMN) { + val model = model as ExpandableEventsTableModel + val tableRow = model.getRowAt(row) + val operationId = tableRow?.event?.operationId + cursor = if (!operationId.isNullOrEmpty() && operationId.trim() != "-") { + Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } else { + Cursor.getDefaultCursor() + } + } else { + cursor = Cursor.getDefaultCursor() + } + } + }) + } + } + + fun updateEventsTable(table: JBTable, events: List, errorMessage: String? = null) { + val model = table.model as ExpandableEventsTableModel + model.setEvents(events, errorMessage) + + // Reapply cell renderers after structure change + reapplyEventsTableRenderers(table) + } + + fun updateEventsTablePage(table: JBTable, page: Int) { + val model = table.model as ExpandableEventsTableModel + val oldColumnCount = table.columnCount + model.setCurrentPage(page) + + // Reapply renderers if column count changed + if (table.columnCount != oldColumnCount) { + reapplyEventsTableRenderers(table) + } + } + + private fun reapplyEventsTableRenderers(table: JBTable) { + // Arrow column renderer for icons + if (table.columnCount > StackEventsTableComponents.ARROW_COLUMN) { + val arrowColumn = table.columnModel.getColumn(StackEventsTableComponents.ARROW_COLUMN) + arrowColumn.cellRenderer = EventsTableCellRenderer() + arrowColumn.preferredWidth = 30 + arrowColumn.maxWidth = 50 + arrowColumn.minWidth = 30 + } + + // Operation column renderer for hyperlinks + if (table.columnCount > StackEventsTableComponents.OPERATION_COLUMN) { + table.columnModel.getColumn(StackEventsTableComponents.OPERATION_COLUMN).cellRenderer = OperationCellRenderer() + } + + // Status column renderer for colors + if (table.columnCount > StackEventsTableComponents.STATUS_COLUMN) { + table.columnModel.getColumn(StackEventsTableComponents.STATUS_COLUMN).cellRenderer = EventsTableCellRenderer() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt new file mode 100644 index 00000000000..e8893178904 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanel.kt @@ -0,0 +1,213 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.UIUtil +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackResourceSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.ConsoleUrlGenerator +import software.aws.toolkits.jetbrains.services.cfnlsp.ui.IconUtils +import java.util.concurrent.CompletableFuture +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.table.DefaultTableModel +import kotlin.math.ceil + +internal class StackResourcesPanel( + project: Project, + coordinator: StackViewCoordinator, + private val stackArn: String, + private val stackName: String, +) : Disposable, StackPollingListener { + + private val cfnClientService = CfnClientService.getInstance(project) + private val disposables = mutableListOf() + + // Resource data management + private var allResources: List = emptyList() + private var currentPage: Int = 0 + private var nextToken: String? = null + private val resourcesPerPage = 50 + private var isLoading = false // Prevents rapid-fire clicks from triggering multiple concurrent operations + + private val tableModel = DefaultTableModel( + arrayOf("Logical ID", "Physical ID", "Type", "Status"), + 0 + ) + + private val resourceTable = JBTable(tableModel).apply { + setDefaultEditor(Any::class.java, null) // Make table non-editable + autoResizeMode = JBTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + } + + internal val pageLabel = JBLabel("Page 1") + internal val prevButton = JButton("Previous").apply { addActionListener { loadPrevPage() } } + internal val nextButton = JButton("Next").apply { addActionListener { loadNextPage() } } + internal val resourceCountLabel = JBLabel("0 resources").apply { + foreground = UIUtil.getContextHelpForeground() + } + internal val consoleLink = IconUtils.createConsoleLinkIcon { + ConsoleUrlGenerator.generateStackResourcesUrl(stackArn) + }.apply { + isVisible = false // Start hidden until successful load + } + + val component: JComponent = StackPanelLayoutBuilder.createStackTablePanel( + stackName, + resourceTable, + resourceCountLabel, + consoleLink, + PaginationControls(pageLabel, prevButton, nextButton) + ) + + init { + loadResources() + disposables.add(coordinator.addPollingListener(stackArn, this)) + } + + override fun onStackPolled() { + currentPage = 0 + allResources = emptyList() + nextToken = null + isLoading = false // Reset loading flag to ensure refresh happens + loadResources() + } + + private fun loadResources(): CompletableFuture { + if (isLoading) return CompletableFuture.completedFuture(null) + isLoading = true + + val tokenToUse = nextToken + val params = GetStackResourcesParams(stackName, tokenToUse) + + return cfnClientService.getStackResources(params).whenComplete { result, error -> + ApplicationManager.getApplication().invokeLater { + isLoading = false + if (error != null) { + handleError("Failed to load resources: ${error.message}") + } else { + result?.let { response -> + consoleLink.isVisible = true + if (tokenToUse == null) { + // Fresh load - replace all resources + allResources = response.resources + currentPage = 0 + } else { + // Pagination load - append resources + allResources = allResources + response.resources + } + nextToken = response.nextToken + updateTable() + } ?: run { + handleError("No stack data found") + } + } + } + }.thenApply { null } + } + + private fun updateTable() { + tableModel.rowCount = 0 + val startIndex = currentPage * resourcesPerPage + val endIndex = minOf(startIndex + resourcesPerPage, allResources.size) + val pageResources = allResources.subList(startIndex, endIndex) + + pageResources.forEach { resource -> + tableModel.addRow( + arrayOf( + resource.logicalResourceId, + resource.physicalResourceId ?: "N/A", + resource.resourceType, + resource.resourceStatus + ) + ) + } + + // Update pagination controls + pageLabel.text = "Page ${currentPage + 1}" + prevButton.isEnabled = currentPage > 0 && !isLoading + nextButton.isEnabled = hasMorePages() && !isLoading + + // Update resource count + val hasMore = nextToken != null + resourceCountLabel.text = "${allResources.size} resource${if (allResources.size != 1) "s" else ""}${if (hasMore) " loaded" else ""}" + + // Update button text based on whether NEXT click will need to load more from server + nextButton.text = if ((currentPage + 1) * resourcesPerPage >= allResources.size && nextToken != null) { + "Load More" + } else { + "Next" + } + } + + private fun loadNextPage() { + if (isLoading) return + + val totalPages = ceil(allResources.size.toDouble() / resourcesPerPage).toInt() + val nextPageIndex = currentPage + 1 + + if (nextPageIndex >= totalPages && nextToken == null) { + return + } + + if (nextPageIndex < totalPages) { + currentPage = nextPageIndex + updateTable() + } else { + loadResources().whenComplete { _, _ -> + ApplicationManager.getApplication().invokeLater { + if (allResources.size > nextPageIndex * resourcesPerPage) { + currentPage = nextPageIndex + updateTable() + } + } + } + } + } + + private fun loadPrevPage() { + if (isLoading) return + if (currentPage > 0) { + currentPage-- + updateTable() + } + } + + private fun hasMorePages(): Boolean = + (currentPage + 1) * resourcesPerPage < allResources.size || nextToken != null + + private fun handleError(message: String) { + consoleLink.isVisible = false + allResources = emptyList() + currentPage = 0 + nextToken = null + + tableModel.rowCount = 0 + tableModel.addRow(arrayOf(message, "", "", "")) + + // Update pagination controls for error state + pageLabel.text = "Page 1" + prevButton.isEnabled = false + nextButton.isEnabled = false + resourceCountLabel.text = "0 resources" + LOG.warn { message } + } + + override fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt new file mode 100644 index 00000000000..6295a1556f6 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt @@ -0,0 +1,89 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +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.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import java.util.Timer +import java.util.TimerTask + +internal class StackStatusPoller( + project: Project, + private val stackName: String, + private val stackArn: String, // Primary identifier + private val coordinator: StackViewCoordinator, +) : Disposable { + + private val cfnClientService = CfnClientService.getInstance(project) + private var pollingTimer: Timer? = null + private var isViewVisible: Boolean = false + + fun setViewVisible(visible: Boolean) { + isViewVisible = visible + if (visible) { + start() + } else { + stop() + } + } + + fun start() { + stop() // Stop any existing polling + pollingTimer = Timer().apply { + scheduleAtFixedRate( + object : TimerTask() { + override fun run() { + if (isViewVisible) { + fetchStackData() + } + } + }, + 0, POLLING_INTERVAL_MS + ) // Poll every 5 seconds + } + } + + fun stop() { + pollingTimer?.cancel() + pollingTimer = null + } + + private fun fetchStackData() { + cfnClientService.describeStack(DescribeStackParams(stackName)) + .whenComplete { result, error -> + // Ensure coordinator updates happen on EDT + ApplicationManager.getApplication().invokeLater { + if (error != null) { + LOG.warn { "Error fetching stack data for $stackName: ${error.message}" } + } else if (result?.stack == null) { + LOG.warn { "No stack data received for $stackName" } + } else { + val stack = result.stack + coordinator.updateStackStatus(stackArn, stack.stackStatus) + + // Stop polling if stack reaches terminal state + if (!StackStatusUtils.isInTransientState(stack.stackStatus)) { + LOG.info { "Stack $stackName reached terminal state: ${stack.stackStatus}, stopping polling" } + stop() + } + } + } + } + } + + override fun dispose() { + stop() + } + + companion object { + private val LOG = getLogger() + private const val POLLING_INTERVAL_MS = 5000L + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt new file mode 100644 index 00000000000..a935e1a992a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.kt @@ -0,0 +1,111 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +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.warn +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +internal interface StackStatusListener { + fun onStackStatusUpdated() +} + +internal interface StackPollingListener { + fun onStackPolled() +} + +data class StackState( + val stackName: String, + val stackArn: String, + val status: String?, + val lastUpdated: Instant, +) + +@Service(Service.Level.PROJECT) +internal class StackViewCoordinator : Disposable { + private val stackStates = ConcurrentHashMap() + private val statusListeners = ConcurrentHashMap>() + private val pollingListeners = ConcurrentHashMap>() + + fun setStack(stackArn: String, stackName: String) { + val state = StackState(stackName, stackArn, null, Instant.now()) + stackStates[stackArn] = state + notifyListeners(stackArn) + } + + fun updateStackStatus(stackArn: String, status: String) { + stackStates[stackArn]?.let { currentState -> + if (currentState.status != status) { + stackStates[stackArn] = currentState.copy(status = status, lastUpdated = Instant.now()) + notifyListeners(stackArn) + } + // Always notify polling listeners regardless of status change + notifyPollingListeners(stackArn) + } ?: LOG.warn { "Stack not found for status update: $stackArn" } + } + + fun getStackState(stackArn: String): StackState? = stackStates[stackArn] + + fun removeStack(stackArn: String) { + stackStates.remove(stackArn) + statusListeners.remove(stackArn) + pollingListeners.remove(stackArn) + } + + fun addStatusListener(stackArn: String, listener: StackStatusListener): Disposable { + statusListeners.computeIfAbsent(stackArn) { CopyOnWriteArrayList() }.add(listener) + + // Immediately notify new listener of current state + stackStates[stackArn]?.let { + listener.onStackStatusUpdated() + } + + return Disposable { + statusListeners[stackArn]?.remove(listener) + if (statusListeners[stackArn]?.isEmpty() == true) { + statusListeners.remove(stackArn) + } + } + } + + fun addPollingListener(stackArn: String, listener: StackPollingListener): Disposable { + pollingListeners.computeIfAbsent(stackArn) { CopyOnWriteArrayList() }.add(listener) + + return Disposable { + pollingListeners[stackArn]?.remove(listener) + if (pollingListeners[stackArn]?.isEmpty() == true) { + pollingListeners.remove(stackArn) + } + } + } + + private fun notifyListeners(stackArn: String) { + statusListeners[stackArn]?.forEach { + it.onStackStatusUpdated() + } + } + + private fun notifyPollingListeners(stackArn: String) { + pollingListeners[stackArn]?.forEach { + it.onStackPolled() + } + } + + override fun dispose() { + stackStates.clear() + statusListeners.clear() + pollingListeners.clear() + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project): StackViewCoordinator = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt new file mode 100644 index 00000000000..c0d294974a6 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt @@ -0,0 +1,96 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTabbedPane +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.JPanel + +enum class StackViewTab(val index: Int, val title: String) { + OVERVIEW(0, "Overview"), + EVENTS(1, "Events"), + RESOURCES(2, "Resources"), + OUTPUTS(3, "Outputs"), + CHANGE_SET(4, "Change Set"), +} + +internal class StackViewPanelTabber( + project: Project, + internal val stackName: String, + private val stackArn: String, +) : Disposable { + + private val coordinator = StackViewCoordinator.getInstance(project) + private val poller = StackStatusPoller(project, stackName, stackArn, coordinator) + private val overviewPanel = StackOverviewPanel(project, coordinator, stackArn, stackName) + private val resourcesPanel = StackResourcesPanel(project, coordinator, stackArn, stackName) + private val outputsPanel = StackOutputsPanel(project, coordinator, stackArn, stackName) + private val eventsPanel = StackEventsPanel(project, coordinator, stackArn, stackName) + + private val tabbedPane = JBTabbedPane().apply { + addTab(StackViewTab.OVERVIEW.title, createOverviewPanel()) + addTab(StackViewTab.EVENTS.title, createEventsPanel()) + addTab(StackViewTab.RESOURCES.title, createResourcesPanel()) + addTab(StackViewTab.OUTPUTS.title, createOutputsPanel()) + selectedIndex = StackViewTab.OVERVIEW.index + } + + private val changeSetTabIndex: Int + get() = if (tabbedPane.tabCount > StackViewTab.CHANGE_SET.index) StackViewTab.CHANGE_SET.index else -1 + + fun updateChangeSetTab(title: String, component: JComponent, tooltip: String? = null) { + if (changeSetTabIndex >= 0) { + tabbedPane.setComponentAt(StackViewTab.CHANGE_SET.index, component) + tabbedPane.setTitleAt(StackViewTab.CHANGE_SET.index, title) + tabbedPane.setToolTipTextAt(StackViewTab.CHANGE_SET.index, tooltip) + } else { + tabbedPane.insertTab(title, null, component, tooltip, StackViewTab.CHANGE_SET.index) + } + tabbedPane.selectedIndex = StackViewTab.CHANGE_SET.index + } + + fun removeChangeSetTab() { + if (changeSetTabIndex >= 0) tabbedPane.removeTabAt(StackViewTab.CHANGE_SET.index) + } + + fun switchToTab(tab: StackViewTab) { + tabbedPane.selectedIndex = tab.index + } + + fun restartStatusPolling() { + poller.start() + } + + private val mainPanel = JBPanel>(BorderLayout()).apply { + add(tabbedPane, BorderLayout.CENTER) + } + + private fun createOverviewPanel(): JComponent = overviewPanel.component + + private fun createResourcesPanel(): JComponent = resourcesPanel.component + + private fun createEventsPanel(): JComponent = eventsPanel.component + + private fun createOutputsPanel(): JComponent = outputsPanel.component + + fun start() { + coordinator.setStack(stackArn, stackName) + poller.setViewVisible(true) + } + + fun getComponent(): JPanel = mainPanel + + override fun dispose() { + poller.dispose() + overviewPanel.dispose() + resourcesPanel.dispose() + outputsPanel.dispose() + eventsPanel.dispose() + coordinator.removeStack(stackArn) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt new file mode 100644 index 00000000000..b019803903b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewToolWindowFactory.kt @@ -0,0 +1,25 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import software.aws.toolkits.resources.message + +internal class StackViewToolWindowFactory : ToolWindowFactory { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + runInEdt { + toolWindow.installWatcher(toolWindow.contentManager) + } + // Don't create any initial content - tabs will be created when stacks are opened + } + + override fun init(toolWindow: ToolWindow) { + toolWindow.stripeTitle = message("cloudformation.lsp.stack.view") + } + + override fun shouldBeAvailable(project: Project): Boolean = false +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt new file mode 100644 index 00000000000..7d57f321444 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt @@ -0,0 +1,188 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.Content +import com.intellij.ui.content.ContentFactory +import com.intellij.ui.content.ContentManager +import com.intellij.ui.content.ContentManagerEvent +import com.intellij.ui.content.ContentManagerListener +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.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import java.util.concurrent.ConcurrentHashMap + +@Service(Service.Level.PROJECT) +internal class StackViewWindowManager(private val project: Project) { + + private val activeStacks = ConcurrentHashMap() + private var listenerRegistered = false + + fun getTabberByName(stackName: String): StackViewPanelTabber? = + activeStacks.values.firstOrNull { it.stackName == stackName } + + fun getOrOpenTabber(stackName: String): StackViewPanelTabber? { + var tabber = getTabberByName(stackName) + if (tabber == null) { + try { + val stackResult = CfnClientService.getInstance(project) + .describeStack(DescribeStackParams(stackName)).get() + val stackId = stackResult?.stack?.stackId + + if (stackId == null) { + LOG.error { "Failed to get stackId for stack: $stackName" } + return null + } + + openStack(stackName, stackId) + tabber = getTabberByName(stackName) + } catch (e: Exception) { + LOG.error(e) { "Failed to ensure stack view is open for $stackName" } + return null + } + } + return tabber + } + + fun openStack(stackName: String, stackId: String) { + LOG.info { "openStack called for stackName: $stackName, stackId: $stackId" } + + val toolWindowManager = ToolWindowManager.getInstance(project) + val toolWindow = toolWindowManager.getToolWindow(TOOL_WINDOW_ID) + + if (toolWindow == null) { + LOG.error { "Tool window '$TOOL_WINDOW_ID' not found" } + return + } + + // Make tool window available if it's not already + if (!toolWindow.isAvailable) { + toolWindow.setAvailable(true, null) + } + + val contentManager = toolWindow.contentManager + + // Check if tab already exists + val existingContent = contentManager.contents.find { + it.getUserData(STACK_ARN_KEY) == stackId + } + + if (existingContent != null) { + contentManager.setSelectedContent(existingContent) + runInEdt { + toolWindow.show() + toolWindow.activate(null, true) + } + return + } + + if (contentManager.contentCount >= MAX_TABS) { + removeOldestTab() + } + + // Create new stack view + val stackView = try { + StackViewPanelTabber(project, stackName, stackId) + } catch (e: Exception) { + LOG.error(e) { "Failed to create StackDetailView" } + return + } + + val content = ContentFactory.getInstance().createContent( + stackView.getComponent(), + stackName, // Tab title is stack name, key is stack arn + true + ) + content.putUserData(STACK_ARN_KEY, stackId) + + setupContentManagerListener(contentManager) + + contentManager.addContent(content) + contentManager.setSelectedContent(content) + + activeStacks[stackId] = stackView + + setupStackStatusListener(stackId, stackName, content) + + stackView.start() + + runInEdt { + toolWindow.show() + toolWindow.activate(null, true) + } + } + + private fun setupContentManagerListener(contentManager: ContentManager) { + if (!listenerRegistered) { + contentManager.addContentManagerListener(object : ContentManagerListener { + override fun contentRemoved(event: ContentManagerEvent) { + val removedContent = event.content + val stackArn = removedContent.getUserData(STACK_ARN_KEY) + if (stackArn != null) { + LOG.info { "Tab closed by user, disposing resources for stack: $stackArn" } + activeStacks[stackArn]?.dispose() + activeStacks.remove(stackArn) + } + } + }) + listenerRegistered = true + } + } + + private fun setupStackStatusListener(stackId: String, stackName: String, content: Content) { + val coordinator = StackViewCoordinator.getInstance(project) + coordinator.addStatusListener( + stackId, + object : StackStatusListener { + override fun onStackStatusUpdated() { + val stackState = coordinator.getStackState(stackId) + val status = stackState?.status + LOG.info { "Updating tab title for stack: $stackId, status: $status" } + runInEdt { + val displayName = if (status != null) { + "$stackName [$status]" + } else { + stackName + } + content.displayName = displayName + } + } + } + ) + } + + private fun removeOldestTab() { + val toolWindowManager = ToolWindowManager.getInstance(project) + val toolWindow = toolWindowManager.getToolWindow(TOOL_WINDOW_ID) ?: return + val contentManager = toolWindow.contentManager + + if (contentManager.contentCount > 0) { + val oldestContent = contentManager.contents.first() + val stackArn = oldestContent.getUserData(STACK_ARN_KEY) + stackArn?.let { + activeStacks[it]?.dispose() + activeStacks.remove(it) + } + contentManager.removeContent(oldestContent, true) + } + } + + companion object { + private val LOG = getLogger() + private val STACK_ARN_KEY = Key.create("STACK_ARN") + + private const val MAX_TABS = 10 + private const val TOOL_WINDOW_ID = "cloudformation.lsp.stack.view" + + fun getInstance(project: Project): StackViewWindowManager = project.service() + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt new file mode 100644 index 00000000000..c6e0273edb7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt @@ -0,0 +1,39 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.JBColor +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.awt.Color +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +internal object StackStatusUtils { + fun getStatusColors(status: String): Pair = when { + status.contains("COMPLETE") && !status.contains("ROLLBACK") -> + JBColor.GREEN to JBColor.BLACK + status.contains("FAILED") || status.contains("ROLLBACK") -> + JBColor.RED to JBColor.BLACK + status.contains("PROGRESS") -> + JBColor.YELLOW to JBColor.BLACK + else -> null to null + } + + fun isInTransientState(status: String): Boolean = status.contains("_IN_PROGRESS") +} + +internal object StackDateFormatter { + private val LOG = getLogger() + private val dateFormatter = DateTimeFormatter.ofPattern("d/M/yyyy, h:mm:ss a") + + fun formatDate(dateString: String): String? = try { + val instant = Instant.parse(dateString) + instant.atZone(ZoneId.systemDefault()).format(dateFormatter) + } catch (e: Exception) { + LOG.warn(e) { "Failed to parse date string: $dateString" } + null + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt new file mode 100644 index 00000000000..dcd84f25cc2 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDiffPanel.kt @@ -0,0 +1,532 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import com.intellij.diff.DiffContentFactory +import com.intellij.diff.DiffManager +import com.intellij.diff.requests.SimpleDiffRequest +import com.intellij.icons.AllIcons +import com.intellij.json.JsonFileType +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.ui.Splitter +import com.intellij.ui.JBColor +import com.intellij.ui.PopupHandler +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChange +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChangeDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackChange +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetDeletionWorkflow +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.DeploymentWorkflow +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import java.awt.BorderLayout +import java.awt.Component +import java.awt.Font +import java.awt.Point +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.BoxLayout +import javax.swing.ListSelectionModel +import javax.swing.event.ListSelectionEvent +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +private const val DRIFT_WARNING = "\u26A0\uFE0F" + +internal class ChangeSetDiffPanel( + private val project: Project, + private val stackName: String, + private val changeSetName: String, + changes: List, + private val enableDeploy: Boolean, + private val status: String? = null, + private val creationTime: String? = null, + private val description: String? = null, +) : SimpleToolWindowPanel(false, true) { + + private val resourceChanges = changes.mapNotNull { it.resourceChange } + private val hasDrift = resourceChanges.any { it.hasDrift() } + private val resourceTable = JBTable(ResourceTableModel(resourceChanges, hasDrift)).apply { + setSelectionMode(ListSelectionModel.SINGLE_SELECTION) + setShowGrid(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) showResourceDiff() + } + }) + addMouseListener(object : PopupHandler() { + override fun invokePopup(comp: Component, x: Int, y: Int) { + val row = rowAtPoint(Point(x, y)) + if (row < 0) return + setRowSelectionInterval(row, row) + val menu = ActionManager.getInstance().createActionPopupMenu( + "ChangeSetDiffContext", + DefaultActionGroup( + object : AnAction("View Diff", null, AllIcons.Actions.Diff) { + override fun actionPerformed(e: AnActionEvent) { + showResourceDiff() + } + } + ) + ) + menu.component.show(comp, x, y) + } + }) + } + + private val hasAnyDetails = resourceChanges.any { !it.details.isNullOrEmpty() } + private val detailTable = JBTable().apply { setShowGrid(false) } + private val detailPanel = JBPanel>(BorderLayout()) + + init { + if (hasDrift) applyDriftRenderer() + + resourceTable.selectionModel.addListSelectionListener { e: ListSelectionEvent -> + if (!e.valueIsAdjusting) onResourceSelected() + } + + val headerPanel = buildHeaderPanel() + + val tableContent = if (hasAnyDetails) { + Splitter(true, 0.55f).apply { + firstComponent = JBScrollPane(resourceTable) + secondComponent = detailPanel + } + } else { + JBScrollPane(resourceTable) + } + + val mainContent = JBPanel>(BorderLayout()).apply { + add(headerPanel, BorderLayout.NORTH) + add(tableContent, BorderLayout.CENTER) + } + + toolbar = createToolbar() + setContent(mainContent) + } + + private fun buildHeaderPanel(): JBPanel> = JBPanel>().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + border = JBUI.Borders.empty(6, 8) + + val parts = buildList { + add(changeSetName) + status?.let { add("Status: $it") } + creationTime?.let { add("Created: $it") } + description?.let { add(it) } + } + val label = JBLabel(parts.joinToString(" | ")).apply { + foreground = JBColor.GRAY + } + add(label) + add(javax.swing.JSeparator().apply { border = JBUI.Borders.emptyTop(4) }) + } + + private fun applyDriftRenderer() { + val driftColIndex = resourceTable.columnCount - 1 + resourceTable.columnModel.getColumn(driftColIndex).cellRenderer = WarningCellRenderer() + } + + private fun onResourceSelected() { + if (!hasAnyDetails) return + val row = resourceTable.selectedRow + if (row < 0 || row >= resourceChanges.size) return + val rc = resourceChanges[row] + val details = rc.details + + detailPanel.removeAll() + if (!details.isNullOrEmpty()) { + val hasDetailDrift = details.any { it.target?.drift != null || it.target?.liveResourceDrift != null } + detailTable.model = DetailTableModel(details, hasDetailDrift) + if (hasDetailDrift) applyDetailDriftRenderer() + val title = JBLabel("Property-level changes").apply { + font = font.deriveFont(Font.BOLD) + border = JBUI.Borders.empty(4, 6) + } + detailPanel.add(title, BorderLayout.NORTH) + detailPanel.add(JBScrollPane(detailTable), BorderLayout.CENTER) + } + detailPanel.revalidate() + detailPanel.repaint() + } + + private fun applyDetailDriftRenderer() { + val colCount = detailTable.columnCount + val renderer = WarningCellRenderer() + detailTable.columnModel.getColumn(colCount - 2).cellRenderer = renderer + detailTable.columnModel.getColumn(colCount - 1).cellRenderer = renderer + } + + private fun showResourceDiff() { + val row = resourceTable.selectedRow + if (row < 0 || row >= resourceChanges.size) return + val rc = resourceChanges[row] + val before = formatJson(rc.beforeContext.orEmpty()) + val after = formatJson(rc.afterContext.orEmpty()) + if (before.isEmpty() && after.isEmpty()) return + + val annotatedBefore = annotateDriftInJson(rc, before) + val factory = DiffContentFactory.getInstance() + val title = "${rc.logicalResourceId ?: "Resource"} — $stackName" + DiffManager.getInstance().showDiff( + project, + SimpleDiffRequest( + title, + factory.create(annotatedBefore, JsonFileType.INSTANCE), + factory.create(after, JsonFileType.INSTANCE), + "Before", + "After" + ) + ) + } + + private fun formatJson(raw: String): String { + if (raw.isBlank()) return "" + return try { + GsonBuilder().setPrettyPrinting().create().toJson(JsonParser.parseString(raw)) + } catch (_: Exception) { + raw + } + } + + private fun showAllResourcesDiff() { + val beforeData = mutableMapOf() + val afterData = mutableMapOf() + + for (rc in resourceChanges) { + val id = rc.logicalResourceId ?: continue + + if (rc.action != "Add" || rc.resourceDriftStatus == "DELETED") { + beforeData[id] = parseJsonOrEmpty(rc.beforeContext) + } + if (rc.action != "Remove") { + afterData[id] = parseJsonOrEmpty(rc.afterContext) + } + + if (rc.beforeContext == null && rc.afterContext == null) { + rc.details?.forEach { detail -> + val target = detail.target ?: return@forEach + val name = target.name ?: return@forEach + if (rc.action != "Add") { + @Suppress("UNCHECKED_CAST") + (beforeData.getOrPut(id) { mutableMapOf() } as MutableMap)[name] = + target.beforeValue ?: "" + } + if (rc.action != "Remove") { + @Suppress("UNCHECKED_CAST") + (afterData.getOrPut(id) { mutableMapOf() } as MutableMap)[name] = + target.afterValue ?: "" + } + } + } + } + + val gson = GsonBuilder().setPrettyPrinting().create() + val beforeJson = gson.toJson(beforeData) + val afterJson = gson.toJson(afterData) + + val annotatedBefore = annotateDriftInJsonAll(resourceChanges, beforeJson) + val factory = DiffContentFactory.getInstance() + DiffManager.getInstance().showDiff( + project, + SimpleDiffRequest( + "$stackName: Before \u2194 After", + factory.create(annotatedBefore, JsonFileType.INSTANCE), + factory.create(afterJson, JsonFileType.INSTANCE), + "Before", + "After" + ) + ) + } + + private fun parseJsonOrEmpty(raw: String?): Any { + if (raw.isNullOrBlank()) return emptyMap() + return try { + GsonBuilder().create().fromJson(raw, Map::class.java) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + } + + private fun createToolbar() = ActionManager.getInstance().createActionToolbar( + "ChangeSetDiff", + DefaultActionGroup().apply { + if (enableDeploy) { + add( + object : AnAction( + "Execute Change Set", + "Execute this change set", + AllIcons.Actions.Execute, + ) { + override fun actionPerformed(e: AnActionEvent) { + DeploymentWorkflow(project).deploy(stackName, changeSetName) + } + } + ) + } + add( + object : AnAction( + "View Diff", + "View side-by-side diff of all changes", + AllIcons.Actions.Diff, + ) { + override fun actionPerformed(e: AnActionEvent) { + showAllResourcesDiff() + } + } + ) + add( + object : AnAction( + "Delete Change Set", + "Delete this change set", + AllIcons.Actions.GC, + ) { + override fun actionPerformed(e: AnActionEvent) { + val msg = "Delete change set '$changeSetName'?" + if (Messages.showYesNoDialog(project, msg, "Delete Change Set", null) == Messages.YES) { + ChangeSetDeletionWorkflow(project).delete(stackName, changeSetName) + } + } + } + ) + }, + true + ).apply { + targetComponent = this@ChangeSetDiffPanel + }.component + + companion object { + private val LOG = getLogger() + + fun show( + project: Project, + stackName: String, + changeSetName: String, + changes: List, + enableDeploy: Boolean, + status: String? = null, + creationTime: String? = null, + description: String? = null, + ) { + val panel = ChangeSetDiffPanel( + project, + stackName, + changeSetName, + changes, + enableDeploy, + status, + creationTime, + description, + ) + + try { + val tabber = StackViewWindowManager.getInstance(project).getOrOpenTabber(stackName) + if (tabber != null) { + tabber.updateChangeSetTab("Change set", panel, tooltip = changeSetName) + } else { + LOG.error { "Failed to get tabber for stack: $stackName" } + } + } catch (e: Exception) { + LOG.error(e) { "Exception while getting or opening stack view for $stackName" } + } + } + } +} + +internal fun annotateDriftInJson(rc: ResourceChange, beforeJson: String): String { + val driftByPath = mutableMapOf() + + if (rc.resourceDriftStatus == "DELETED") return "// $DRIFT_WARNING Resource deleted out-of-band\n$beforeJson" + + rc.details?.forEach { detail -> + val target = detail.target ?: return@forEach + val drift = target.drift ?: target.liveResourceDrift ?: return@forEach + val path = target.path ?: return@forEach + if (drift.actualValue == null) return@forEach + driftByPath[path] = drift.actualValue + } + if (driftByPath.isEmpty()) return beforeJson + + val lines = beforeJson.lines().toMutableList() + for ((path, actualValue) in driftByPath) { + val lineIndex = findPropertyLine(lines, path) + if (lineIndex >= 0) { + lines[lineIndex] = "${lines[lineIndex]} \u2190 $DRIFT_WARNING Drifted (Live AWS: $actualValue)" + } + } + return lines.joinToString("\n") +} + +private fun annotateDriftInJsonAll(resourceChanges: List, beforeJson: String): String { + val lines = beforeJson.lines().toMutableList() + for (rc in resourceChanges) { + rc.details?.forEach { detail -> + val target = detail.target ?: return@forEach + val drift = target.drift ?: target.liveResourceDrift ?: return@forEach + val path = target.path ?: return@forEach + if (drift.actualValue == null) return@forEach + + val id = rc.logicalResourceId ?: return@forEach + val resourceLine = lines.indexOfFirst { it.contains("\"$id\"") } + if (resourceLine < 0) return@forEach + + if (rc.resourceDriftStatus == "DELETED") { + lines[resourceLine] = "${lines[resourceLine]} \u2190 $DRIFT_WARNING Resource deleted out-of-band" + return@forEach + } + + val lineIndex = findPropertyLine(lines, path, startFrom = resourceLine) + if (lineIndex >= 0) { + lines[lineIndex] = "${lines[lineIndex]} \u2190 $DRIFT_WARNING Drifted (Live AWS: ${drift.actualValue})" + } + } + } + return lines.joinToString("\n") +} + +private fun findPropertyLine(lines: List, path: String, startFrom: Int = 0): Int { + val parts = path.split("/").filter { it.isNotEmpty() } + var currentLine = startFrom + for (part in parts) { + if (part.all { it.isDigit() }) continue + val found = (currentLine + 1 until lines.size).firstOrNull { lines[it].contains("\"$part\"") } + if (found == null) return -1 + currentLine = found + } + return currentLine +} + +private fun ResourceChange.hasDrift(): Boolean = + resourceDriftStatus != null || + details?.any { it.target?.drift != null || it.target?.liveResourceDrift != null } == true + +internal fun ResourceChange.driftDisplay(): String { + if (resourceDriftStatus == "DELETED") return "$DRIFT_WARNING Deleted" + if (details?.any { it.target?.drift != null || it.target?.liveResourceDrift != null } == true) return "$DRIFT_WARNING Modified" + if (resourceDriftStatus != null && resourceDriftStatus != "IN_SYNC") return "$DRIFT_WARNING $resourceDriftStatus" + return "-" +} + +private class ResourceTableModel( + private val resources: List, + private val showDrift: Boolean, +) : AbstractTableModel() { + + private val columns = if (showDrift) { + arrayOf( + "Action", + "Logical ID", + "Physical ID", + "Type", + "Replacement", + "Drift Status", + ) + } else { + arrayOf( + "Action", + "Logical ID", + "Physical ID", + "Type", + "Replacement", + ) + } + + override fun getRowCount() = resources.size + override fun getColumnCount() = columns.size + override fun getColumnName(col: Int) = columns[col] + override fun getValueAt(row: Int, col: Int): Any { + val rc = resources[row] + return when (col) { + 0 -> rc.action.orEmpty() + 1 -> rc.logicalResourceId.orEmpty() + 2 -> rc.physicalResourceId.orEmpty() + 3 -> rc.resourceType.orEmpty() + 4 -> rc.replacement.orEmpty() + 5 -> if (showDrift) rc.driftDisplay() else "" + else -> "" + } + } +} + +private class DetailTableModel( + private val details: List, + private val showDrift: Boolean, +) : AbstractTableModel() { + + private val columns = if (showDrift) { + arrayOf( + "Attribute Change Type", + "Name", + "Requires Recreation", + "Before Value", + "After Value", + "Change Source", + "Causing Entity", + "Drift: Previous", + "Drift: Actual", + ) + } else { + arrayOf( + "Attribute Change Type", + "Name", + "Requires Recreation", + "Before Value", + "After Value", + "Change Source", + "Causing Entity", + ) + } + + override fun getRowCount() = details.size + override fun getColumnCount() = columns.size + override fun getColumnName(col: Int) = columns[col] + override fun getValueAt(row: Int, col: Int): Any { + val d = details[row] + val t = d.target + val drift = t?.drift ?: t?.liveResourceDrift + return when (col) { + 0 -> t?.attributeChangeType.orEmpty() + 1 -> t?.name ?: t?.attribute.orEmpty() + 2 -> t?.requiresRecreation.orEmpty() + 3 -> t?.beforeValue.orEmpty() + 4 -> t?.afterValue.orEmpty() + 5 -> d.changeSource.orEmpty() + 6 -> d.causingEntity.orEmpty() + 7 -> if (showDrift) drift?.previousValue ?: "-" else "" + 8 -> if (showDrift) drift?.actualValue ?: "-" else "" + else -> "" + } + } +} + +private class WarningCellRenderer : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: javax.swing.JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) + val text = value?.toString().orEmpty() + if (text.isNotBlank() && text != "-") { + foreground = if (isSelected) table.selectionForeground else JBColor.YELLOW.darker() + } + return comp + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt new file mode 100644 index 00000000000..7aa6ce38103 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeDialogUtils.kt @@ -0,0 +1,49 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +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.warn +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager + +object ResourceTypeDialogUtils { + private val LOG = getLogger() + + internal fun showResourceTypeSelectionDialog(project: Project, resourceTypesManager: ResourceTypesManager) { + val availableTypes = resourceTypesManager.getAvailableResourceTypes() + val selectedTypes = resourceTypesManager.getSelectedResourceTypes() + + if (availableTypes.isEmpty()) { + return + } + + try { + val dialog = ResourceTypeSelectionDialog(project, availableTypes, selectedTypes) + if (dialog.showAndGet()) { + // Handle both additions and removals + val newSelections = dialog.selectedResourceTypes.toSet() + + // Add new selections + newSelections.forEach { type -> + if (type !in selectedTypes) { + resourceTypesManager.addResourceType(type) + } + } + + // Remove deselected types + selectedTypes.forEach { type -> + if (type !in newSelections) { + resourceTypesManager.removeResourceType(type) + } + } + + LOG.info { "Finished updating resource types" } + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to show dialog" } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt new file mode 100644 index 00000000000..7845b47308e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ResourceTypeSelectionDialog.kt @@ -0,0 +1,171 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.psi.codeStyle.NameUtil +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.Component +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JTable +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer + +internal class ResourceTypeSelectionDialog( + project: Project, + private val availableTypes: List, + selectedTypes: Set = emptySet(), +) : DialogWrapper(project) { + var selectedResourceTypes: List = emptyList() + private set + + private val searchField = SearchTextField(false) + private val currentSelections = selectedTypes.toMutableSet() + private val tableModel = ResourceTypeTableModel(availableTypes, currentSelections) + private val table = createTable() + + init { + title = message("cloudformation.explorer.resources.dialog.title") + init() + setupSearch() + filterList() + } + + override fun getPreferredFocusedComponent() = searchField + + private fun createTable() = JBTable(tableModel).apply { + setShowGrid(false) + + tableHeader = null + rowHeight = TABLE_ROW_HEIGHT + + setDefaultRenderer(Boolean::class.java, ResourceTypeCellRenderer(tableModel)) + + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val table = e.source as JTable + val row = table.rowAtPoint(e.point) + if (row >= 0) { + val currentValue = tableModel.getValueAt(row, 0) + tableModel.setValueAt(!currentValue, row, 0) + searchField.requestFocus() + } + } + }) + } + + private fun setupSearch() { + searchField.addDocumentListener(object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) = filterList() + override fun removeUpdate(e: DocumentEvent?) = filterList() + override fun changedUpdate(e: DocumentEvent?) = filterList() + }) + } + + private fun filterList() { + val searchText = searchField.text + val filteredTypes = if (searchText.isEmpty()) { + availableTypes + } else { + val matcher = NameUtil.buildMatcher("*$searchText*", NameUtil.MatchingCaseSensitivity.NONE) + availableTypes.filter { matcher.matches(it) } + } + + tableModel.updateFilter(filteredTypes) + } + + override fun createCenterPanel() = panel { + row { + cell(searchField).align(AlignX.FILL) + } + row { + scrollCell(table).align(Align.FILL) + }.resizableRow() + }.apply { + preferredSize = JBUI.size(DIALOG_WIDTH, DIALOG_HEIGHT) + } + + override fun doOKAction() { + selectedResourceTypes = currentSelections.toList() + super.doOKAction() + } + + companion object { + private const val TABLE_ROW_HEIGHT = 24 + private const val DIALOG_WIDTH = 400 + private const val DIALOG_HEIGHT = 300 + } +} + +private class ResourceTypeTableModel( + availableTypes: List, + private val selections: MutableSet, +) : AbstractTableModel() { + private var filteredTypes = availableTypes.toList() + + override fun getRowCount() = filteredTypes.size + override fun getColumnCount() = 1 + override fun getColumnClass(col: Int) = Boolean::class.java + override fun getValueAt(row: Int, col: Int) = filteredTypes[row] in selections + override fun isCellEditable(row: Int, col: Int) = true + + override fun setValueAt(value: Any, row: Int, col: Int) { + if (value is Boolean) { + val type = filteredTypes[row] + + if (value) selections.add(type) else selections.remove(type) + + fireTableCellUpdated(row, col) + } + } + + fun updateFilter(types: List) { + filteredTypes = types + fireTableDataChanged() + } + + fun getResourceType(row: Int) = filteredTypes[row] +} + +private class ResourceTypeCellRenderer( + private val tableModel: ResourceTypeTableModel, +) : DefaultTableCellRenderer() { + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val label = JBLabel(tableModel.getResourceType(row)) + + label.icon = if (value as Boolean) AllIcons.Actions.Checked else EmptyIcon.ICON_16 + label.iconTextGap = ICON_TEXT_GAP + label.border = JBUI.Borders.emptyLeft(LEFT_BORDER) + label.background = if (isSelected) table.selectionBackground else table.background + label.foreground = if (isSelected) table.selectionForeground else table.foreground + label.isOpaque = true + + return label + } + + companion object { + private const val ICON_TEXT_GAP = 8 + private const val LEFT_BORDER = 8 + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt new file mode 100644 index 00000000000..61456e91848 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt @@ -0,0 +1,84 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import software.amazon.awssdk.arns.Arn +import java.awt.AlphaComposite +import java.awt.Cursor +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage +import java.net.URLEncoder +import javax.swing.Icon +import javax.swing.ImageIcon + +internal object ConsoleUrlGenerator { + fun generateUrl(arn: String): String = + "https://console.aws.amazon.com/go/view?arn=${URLEncoder.encode(arn, "UTF-8")}" + + fun generateStackResourcesUrl(stackArn: String): String = + arnToConsoleTabUrl(stackArn, "resources") + + fun generateStackOutputsUrl(stackArn: String): String = + arnToConsoleTabUrl(stackArn, "outputs") + + fun generateStackEventsUrl(stackArn: String): String = + arnToConsoleTabUrl(stackArn, "events") + + fun generateOperationUrl(arn: String, operationId: String): String { + val region = try { + Arn.fromString(arn).region().orElse("us-east-1") + } catch (e: Exception) { + "us-east-1" + } + return "https://$region.console.aws.amazon.com/cloudformation/home?region=$region#/stacks/operations/info?stackId=${URLEncoder.encode( + arn, + "UTF-8" + )}&operationId=$operationId" + } + + private fun arnToConsoleTabUrl(arn: String, tab: String): String { + val region = try { + Arn.fromString(arn).region().orElse("us-east-1") + } catch (e: Exception) { + "us-east-1" + } + return "https://$region.console.aws.amazon.com/cloudformation/home?region=$region#/stacks/$tab?stackId=${URLEncoder.encode(arn, "UTF-8")}" + } +} + +internal object IconUtils { + private fun createBlueIcon(): Icon { + val size = 16 + val image = UIUtil.createImage(size, size, BufferedImage.TYPE_INT_ARGB) + val g2d = image.createGraphics() + + AllIcons.Ide.External_link_arrow.paintIcon(null, g2d, 0, 0) + + g2d.color = JBColor(0x0366D6, 0x58A6FF) + g2d.composite = AlphaComposite.SrcAtop + g2d.fillRect(0, 0, size, size) + + g2d.dispose() + return ImageIcon(image) + } + + fun createConsoleLinkIcon(urlProvider: () -> String?): JBLabel = + JBLabel(createBlueIcon()).apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + isVisible = false + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + urlProvider()?.let { url -> + BrowserUtil.browse(url) + } + } + }) + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt new file mode 100644 index 00000000000..59564ad8d2b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployDialog.kt @@ -0,0 +1,623 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.ide.wizard.AbstractWizard +import com.intellij.ide.wizard.StepAdapter +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.CheckBoxList +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBUI +import software.aws.toolkits.jetbrains.services.cfnlsp.documents.CfnDocumentManager +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeploymentMode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Parameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceToImport +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Tag +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateParameter +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.TemplateResource +import software.aws.toolkits.jetbrains.services.cfnlsp.server.CFN_SUPPORTED_EXTENSIONS +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.awt.BorderLayout +import java.io.File +import java.net.URI +import javax.swing.DefaultComboBoxModel +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.table.AbstractTableModel + +internal data class TemplateItem( + val displayName: String, + val uri: String?, +) { + override fun toString() = displayName +} + +internal data class ValidateAndDeploySettings( + val templatePath: String, + val stackName: String, + val s3Bucket: String?, + val s3Key: String?, + val parameters: List, + val capabilities: List, + val tags: List, + val onStackFailure: String?, + val includeNestedStacks: Boolean, + val importExistingResources: Boolean, + val deploymentMode: DeploymentMode?, + val resourcesToImport: List?, +) + +internal class ValidateAndDeployWizard( + project: Project, + documentManager: CfnDocumentManager, + prefilledTemplatePath: String? = null, + prefilledStackName: String? = null, + templateParameters: List = emptyList(), + detectedCapabilities: List = emptyList(), + existingParameters: List? = null, + existingTags: List? = null, + hasArtifacts: Boolean = false, + templateResources: List = emptyList(), + isExistingStack: Boolean = false, +) : AbstractWizard(message("cloudformation.deploy.dialog.title"), project) { + + private val configStep = ConfigurationStep( + project, documentManager, prefilledTemplatePath, prefilledStackName, + templateParameters, detectedCapabilities, existingParameters, existingTags, + hasArtifacts, templateResources, isExistingStack, + ) + private val importStep = ImportResourcesStep(templateResources) + + init { + addStep(configStep) + if (templateResources.isNotEmpty()) { + addStep(importStep) + } + configStep.setImportToggleListener { updateWizardButtons() } + init() + } + + override fun getHelpId(): String? = null + + override fun helpAction() { + BrowserUtil.browse(HELP_URL) + } + + override fun doHelpAction() { + BrowserUtil.browse(HELP_URL) + } + + override fun updateButtons() { + super.updateButtons() + if (isLastStep() || !configStep.isImportSelected()) { + nextButton.text = "Create Change Set" + } + } + + override fun canGoNext(): Boolean { + if (currentStep == 0 && !configStep.isImportSelected()) return true + return super.canGoNext() + } + + override fun doNextAction() { + if (currentStep == 0) { + val error = configStep.validate() + if (error != null) { + setErrorText(error) + return + } + setErrorText(null) + } + super.doNextAction() + } + + override fun doOKAction() { + if (currentStep == 0) { + val error = configStep.validate() + if (error != null) { + setErrorText(error) + return + } + } + if (currentStep == 1 || (currentStep == 0 && configStep.isImportSelected())) { + val error = importStep.validate() + if (error != null) { + setErrorText(error) + return + } + } + super.doOKAction() + } + + override fun isLastStep(): Boolean { + if (currentStep == 0 && !configStep.isImportSelected()) return true + return currentStep == stepCount - 1 + } + + fun getSettings(): ValidateAndDeploySettings { + configStep.saveState() + return ValidateAndDeploySettings( + templatePath = configStep.getTemplatePath(), + stackName = configStep.getStackName(), + s3Bucket = configStep.getS3Bucket(), + s3Key = configStep.getS3Key(), + parameters = configStep.getParameters(), + capabilities = configStep.getCapabilities(), + tags = configStep.getTags(), + onStackFailure = configStep.getOnStackFailure(), + includeNestedStacks = configStep.getIncludeNestedStacks(), + importExistingResources = configStep.isImportSelected(), + deploymentMode = configStep.getDeploymentMode(), + resourcesToImport = if (configStep.isImportSelected()) importStep.getResourcesToImport() else null, + ) + } + + companion object { + private const val HELP_URL = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-changesets-create.html" + } +} + +// Step 1: Main configuration +private class ConfigurationStep( + project: Project, + private val documentManager: CfnDocumentManager, + prefilledTemplatePath: String?, + prefilledStackName: String?, + templateParameters: List, + detectedCapabilities: List, + existingParameters: List?, + existingTags: List?, + private val hasArtifacts: Boolean, + templateResources: List, + private val isExistingStack: Boolean, +) : StepAdapter() { + + private val persistence = ValidateAndDeployPersistence.getInstance(project) + private val savedState = persistence.state + + private val descriptor = FileChooserDescriptor(true, false, false, false, false, false).withFileFilter { + it.extension?.lowercase() in CFN_SUPPORTED_EXTENSIONS + } + + private val templateDropdown = ComboBox().apply { + renderer = SimpleListCellRenderer.create { label, item, _ -> + label.text = item.displayName + label.toolTipText = item.uri?.let { URI(it).path } + } + addActionListener { toolTipText = getSelectedTooltip() } + } + + private val browseButton = JButton().apply { + icon = AllIcons.General.OpenDisk + addActionListener { + val selectedFile = FileChooser.chooseFile(descriptor, project, null) + selectedFile?.let { file -> addFileToDropdown(file.path) } + } + toolTipText = "Browse for CloudFormation template" + margin = JBUI.emptyInsets() + val iconSize = icon.iconWidth + 24 + preferredSize = JBUI.size(iconSize, preferredSize.height) + minimumSize = JBUI.size(iconSize, minimumSize.height) + maximumSize = JBUI.size(iconSize, maximumSize.height) + } + + private val stackNameField = JBTextField().apply { + text = prefilledStackName ?: savedState.lastStackName.orEmpty() + emptyText.text = message("cloudformation.deploy.dialog.stack_name.placeholder") + } + + private val s3BucketField = JBTextField().apply { + text = savedState.s3Bucket.orEmpty() + emptyText.text = "S3 bucket name (optional)" + } + + private val s3KeyField = JBTextField().apply { + val defaultKey = if (prefilledTemplatePath != null) { + val f = File(prefilledTemplatePath) + "${f.nameWithoutExtension}-${System.currentTimeMillis()}.${f.extension}" + } else { + null + } + text = savedState.s3Key.orEmpty() + emptyText.text = defaultKey ?: "S3 object key (optional)" + } + + private val parameterFields = templateParameters.map { param -> + val prefill = existingParameters?.find { it.parameterKey == param.name }?.parameterValue + ?: param.default?.toString().orEmpty() + param to JBTextField().apply { + text = prefill + emptyText.text = param.description ?: param.type ?: "String" + } + } + + private val capabilityIam = JBCheckBox("CAPABILITY_IAM").apply { + isSelected = "CAPABILITY_IAM" in detectedCapabilities || savedState.capabilities?.contains("CAPABILITY_IAM") == true + } + private val capabilityNamedIam = JBCheckBox("CAPABILITY_NAMED_IAM").apply { + isSelected = "CAPABILITY_NAMED_IAM" in detectedCapabilities || savedState.capabilities?.contains("CAPABILITY_NAMED_IAM") == true + } + private val capabilityAutoExpand = JBCheckBox("CAPABILITY_AUTO_EXPAND").apply { + isSelected = "CAPABILITY_AUTO_EXPAND" in detectedCapabilities || savedState.capabilities?.contains("CAPABILITY_AUTO_EXPAND") == true + } + + private val tagsField = JBTextField().apply { + val existingTagStr = existingTags?.joinToString(",") { "${it.key}=${it.value}" } + text = existingTagStr ?: savedState.tags.orEmpty() + emptyText.text = "key1=value1,key2=value2 (optional)" + } + + private val onStackFailureCombo = ComboBox(DefaultComboBoxModel(arrayOf("DO_NOTHING", "ROLLBACK", "DELETE"))).apply { + selectedItem = savedState.onStackFailure ?: "DO_NOTHING" + } + + private val includeNestedStacksCheckbox = JBCheckBox("Include nested stacks").apply { + isSelected = savedState.includeNestedStacks + } + + private val deploymentModeCombo = ComboBox(DefaultComboBoxModel(arrayOf("Standard", "Revert Drift"))).apply { + selectedItem = "Standard" + isEnabled = isExistingStack + } + + private val importResourcesCheckbox = JBCheckBox("Import existing resources") + + private var onImportToggled: (() -> Unit)? = null + + init { + populateTemplateDropdown() + prefilledTemplatePath?.let { addFileToDropdown(it) } + templateDropdown.toolTipText = getSelectedTooltip() + } + + private fun getSelectedTooltip(): String? { + val selectedItem = templateDropdown.selectedItem as? TemplateItem + return selectedItem?.uri?.let { URI(it).path } + } + + private fun populateTemplateDropdown() { + val templates = documentManager.getValidTemplates() + templateDropdown.removeAllItems() + + if (templates.isEmpty()) { + templateDropdown.addItem(TemplateItem("Click browse button to select template", null)) + } else { + templates.sortedBy { it.fileName }.forEach { template -> + templateDropdown.addItem(TemplateItem(template.fileName, template.uri)) + } + } + } + + private fun addFileToDropdown(filePath: String) { + val file = File(filePath) + if (!file.exists() || !file.isFile) return + + val fileUri = "file://$filePath" + + val existingItem = (0 until templateDropdown.itemCount) + .map { templateDropdown.getItemAt(it) } + .find { it.uri == fileUri } + + if (existingItem != null) { + templateDropdown.selectedItem = existingItem + } else { + val newItem = TemplateItem(file.name, fileUri) + templateDropdown.addItem(newItem) + templateDropdown.selectedItem = newItem + } + templateDropdown.toolTipText = getSelectedTooltip() + } + + private val component = panel { + group("Template & Stack") { + row(message("cloudformation.deploy.dialog.template.label")) { + cell(templateDropdown).align(Align.FILL).resizableColumn() + cell(browseButton).align(AlignX.LEFT) + } + row(message("cloudformation.deploy.dialog.stack_name.label")) { + cell(stackNameField).align(Align.FILL) + } + } + if (hasArtifacts || s3BucketField.text.isNotBlank()) { + group("S3 Upload") { + row("Bucket:") { cell(s3BucketField).align(Align.FILL) } + row("Key:") { cell(s3KeyField).align(Align.FILL) } + } + } else { + collapsibleGroup("S3 Upload") { + row("Bucket:") { cell(s3BucketField).align(Align.FILL) } + row("Key:") { cell(s3KeyField).align(Align.FILL) } + } + } + if (parameterFields.isNotEmpty()) { + group("Parameters") { + parameterFields.forEach { (param, field) -> + val label = if (param.allowedValues != null) { + "${param.name} (${param.allowedValues.joinToString(", ")}):" + } else { + "${param.name}:" + } + row(label) { cell(field).align(Align.FILL) } + } + } + } + group("Capabilities") { + row { cell(capabilityIam) } + row { cell(capabilityNamedIam) } + row { cell(capabilityAutoExpand) } + } + if (templateResources.isNotEmpty()) { + group("Resource Import") { + row { cell(importResourcesCheckbox) } + } + } + collapsibleGroup("Advanced Options") { + row("Tags:") { cell(tagsField).align(Align.FILL) } + row("On stack failure:") { cell(onStackFailureCombo) } + if (isExistingStack) { + row("Deployment mode:") { cell(deploymentModeCombo) } + } + row { cell(includeNestedStacksCheckbox) } + } + }.apply { + preferredSize = JBUI.size(550, 550) + } + + override fun getComponent(): JComponent = component + + fun setImportToggleListener(listener: () -> Unit) { + onImportToggled = listener + importResourcesCheckbox.addActionListener { listener() } + } + + fun isImportSelected(): Boolean = importResourcesCheckbox.isSelected + + fun validate(): String? { + val selectedItem = templateDropdown.selectedItem as? TemplateItem + val uri = selectedItem?.uri + ?: return message("cloudformation.deploy.dialog.template.required") + + val file = File(URI(uri)) + if (!file.isFile) return message("cloudformation.deploy.dialog.template.not_found") + if (file.extension.lowercase() !in CFN_SUPPORTED_EXTENSIONS) return message("cloudformation.deploy.dialog.template.invalid_extension") + + val name = stackNameField.text.trim() + if (name.isBlank()) return message("cloudformation.deploy.dialog.stack_name.required") + if (name.length > 128) return message("cloudformation.deploy.dialog.stack_name.too_long") + if (!STACK_NAME_PATTERN.matches(name)) return message("cloudformation.deploy.dialog.stack_name.invalid") + + if (hasArtifacts && s3BucketField.text.isBlank()) return "S3 bucket is required because template contains artifacts" + + val tags = tagsField.text.trim() + if (tags.isNotBlank() && !TAGS_PATTERN.matches(tags)) return "Tags format: key1=value1,key2=value2" + + for ((param, field) in parameterFields) { + validateParameter(field.text.trim(), param)?.let { return it } + } + return null + } + + private fun validateParameter(value: String, param: TemplateParameter): String? { + val actual = value.ifBlank { param.default?.toString().orEmpty() } + if (actual.isBlank()) return "${param.name}: Value is required" + if (param.allowedValues != null && actual !in param.allowedValues.map { it.toString() }) { + return "${param.name}: Must be one of: ${param.allowedValues.joinToString(", ")}" + } + if (param.allowedPattern != null && !Regex(param.allowedPattern).matches(actual)) { + return "${param.name}: Must match pattern: ${param.allowedPattern}" + } + if (param.minLength != null && actual.length < param.minLength) return "${param.name}: Min length: ${param.minLength}" + if (param.maxLength != null && actual.length > param.maxLength) return "${param.name}: Max length: ${param.maxLength}" + if (param.type == "Number") { + val num = actual.toDoubleOrNull() ?: return "${param.name}: Must be a number" + if (param.minValue != null && num < param.minValue.toDouble()) return "${param.name}: Min value: ${param.minValue}" + if (param.maxValue != null && num > param.maxValue.toDouble()) return "${param.name}: Max value: ${param.maxValue}" + } + return null + } + + fun saveState() { + val state = persistence.state + val selectedItem = templateDropdown.selectedItem as? TemplateItem + state.lastTemplatePath = selectedItem?.uri?.let { URI(it).path } + state.lastStackName = stackNameField.text.trim() + state.s3Bucket = s3BucketField.text.trim().ifBlank { null } + state.s3Key = s3KeyField.text.trim().ifBlank { null } + state.onStackFailure = onStackFailureCombo.selectedItem as? String + state.includeNestedStacks = includeNestedStacksCheckbox.isSelected + state.importExistingResources = importResourcesCheckbox.isSelected + state.tags = tagsField.text.trim().ifBlank { null } + val caps = mutableListOf() + if (capabilityIam.isSelected) caps.add("CAPABILITY_IAM") + if (capabilityNamedIam.isSelected) caps.add("CAPABILITY_NAMED_IAM") + if (capabilityAutoExpand.isSelected) caps.add("CAPABILITY_AUTO_EXPAND") + state.capabilities = caps.joinToString(",").ifBlank { null } + } + + fun getTemplatePath(): String { + val selectedItem = templateDropdown.selectedItem as? TemplateItem + return selectedItem?.uri?.let { URI(it).path }.orEmpty() + } + + fun getStackName(): String = stackNameField.text.trim() + fun getS3Bucket(): String? = s3BucketField.text.trim().ifBlank { null } + fun getS3Key(): String? = s3KeyField.text.trim().ifBlank { + if (s3BucketField.text.isNotBlank()) { + val path = getTemplatePath() + if (path.isNotBlank()) { + val f = File(path) + "${f.nameWithoutExtension}-${System.currentTimeMillis()}.${f.extension}" + } else { + null + } + } else { + null + } + } + fun getParameters(): List = parameterFields.map { (param, field) -> + Parameter(param.name, field.text.trim().ifBlank { param.default?.toString().orEmpty() }) + } + fun getCapabilities(): List = mutableListOf().apply { + if (capabilityIam.isSelected) add("CAPABILITY_IAM") + if (capabilityNamedIam.isSelected) add("CAPABILITY_NAMED_IAM") + if (capabilityAutoExpand.isSelected) add("CAPABILITY_AUTO_EXPAND") + } + fun getTags(): List = parseTags(tagsField.text.trim()) + fun getOnStackFailure(): String? = onStackFailureCombo.selectedItem as? String + fun getIncludeNestedStacks(): Boolean = includeNestedStacksCheckbox.isSelected + fun getDeploymentMode(): DeploymentMode? = + if (isExistingStack && deploymentModeCombo.selectedItem == "Revert Drift") DeploymentMode.REVERT_DRIFT else null + + private fun parseTags(input: String): List { + if (input.isBlank()) return emptyList() + return input.split(",").mapNotNull { pair -> + val parts = pair.split("=", limit = 2) + if (parts.size == 2) Tag(parts[0].trim(), parts[1].trim()) else null + } + } + + companion object { + private val STACK_NAME_PATTERN = Regex("^[a-zA-Z][-a-zA-Z0-9]*$") + private val TAGS_PATTERN = Regex("^[^=,]+=[^=,]+(,[^=,]+=[^=,]+)*$") + } +} + +// Step 2: Import resources +private class ImportResourcesStep( + private val templateResources: List, +) : StepAdapter() { + + private val resourceCheckboxList = CheckBoxList().apply { + templateResources.forEach { addItem(it, "${it.logicalId} (${it.type})", false) } + } + + private val identifierTableModel = IdentifierTableModel() + private val identifierTable = JBTable(identifierTableModel).apply { + setShowGrid(true) + } + + private val component = JBPanel>(BorderLayout()).apply { + val topPanel = panel { + group("Select Resources to Import") { + row { + cell( + JBScrollPane(resourceCheckboxList).apply { + preferredSize = JBUI.size(530, 150) + } + ).align(Align.FILL) + } + } + } + + val bottomPanel = panel { + group("Resource Identifiers") { + row { + cell( + JBScrollPane(identifierTable).apply { + preferredSize = JBUI.size(530, 200) + } + ).align(Align.FILL) + } + row { + comment("Enter the physical identifier for each selected resource's primary key") + } + } + } + + add(topPanel, BorderLayout.NORTH) + add(bottomPanel, BorderLayout.CENTER) + preferredSize = JBUI.size(550, 450) + + resourceCheckboxList.setCheckBoxListListener { _, _ -> refreshIdentifierTable() } + } + + private fun refreshIdentifierTable() { + val selected = templateResources.filter { resourceCheckboxList.isItemSelected(it) } + identifierTableModel.updateResources(selected) + } + + override fun getComponent(): JComponent = component + + fun getResourcesToImport(): List? { + val imports = identifierTableModel.getResourcesToImport() + return imports.ifEmpty { null } + } + + fun validate(): String? { + val blankResources = identifierTableModel.getResourcesWithBlankIdentifiers() + if (blankResources.isNotEmpty()) { + return "Missing identifiers for: ${blankResources.joinToString(", ")}" + } + return null + } +} + +private class IdentifierTableModel : AbstractTableModel() { + private val columns = arrayOf("Logical ID", "Type", "Identifier Key", "Identifier Value") + private val rows = mutableListOf() + + data class IdentifierRow( + val logicalId: String, + val type: String, + val key: String, + var value: String, + val prefilled: Boolean, + ) + + fun updateResources(resources: List) { + rows.clear() + for (resource in resources) { + val keys = resource.primaryIdentifierKeys ?: continue + for (key in keys) { + val prefilledValue = resource.primaryIdentifier?.get(key) + rows.add(IdentifierRow(resource.logicalId, resource.type, key, prefilledValue.orEmpty(), prefilledValue != null)) + } + } + fireTableDataChanged() + } + + fun getResourcesToImport(): List { + val grouped = rows.groupBy { it.logicalId } + return grouped.mapNotNull { (logicalId, identifierRows) -> + val identifiers = identifierRows.associate { it.key to it.value } + if (identifiers.values.any { it.isBlank() }) return@mapNotNull null + val type = identifierRows.first().type + ResourceToImport(resourceType = type, logicalResourceId = logicalId, resourceIdentifier = identifiers) + } + } + + fun getResourcesWithBlankIdentifiers(): List = + rows.filter { it.value.isBlank() }.map { "${it.logicalId}.${it.key}" }.distinct() + + override fun getRowCount() = rows.size + override fun getColumnCount() = columns.size + override fun getColumnName(col: Int) = columns[col] + override fun isCellEditable(row: Int, col: Int) = col == 3 + override fun getValueAt(row: Int, col: Int): Any = when (col) { + 0 -> rows[row].logicalId + 1 -> rows[row].type + 2 -> rows[row].key + 3 -> rows[row].value + else -> "" + } + override fun setValueAt(value: Any?, row: Int, col: Int) { + if (col == 3) { + rows[row].value = value?.toString().orEmpty() + fireTableCellUpdated(row, col) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt new file mode 100644 index 00000000000..df804e92e46 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ValidateAndDeployPersistence.kt @@ -0,0 +1,37 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +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 +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +@State(name = "cfnValidateAndDeploySettings", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class ValidateAndDeployPersistence : PersistentStateComponent { + private var state = ValidateAndDeployPersistenceState() + + override fun getState(): ValidateAndDeployPersistenceState = state + override fun loadState(state: ValidateAndDeployPersistenceState) { this.state = state } + + companion object { + fun getInstance(project: Project): ValidateAndDeployPersistence = project.service() + } +} + +internal data class ValidateAndDeployPersistenceState( + var lastTemplatePath: String? = null, + var lastStackName: String? = null, + var s3Bucket: String? = null, + var s3Key: String? = null, + var onStackFailure: String? = null, + var includeNestedStacks: Boolean = false, + var importExistingResources: Boolean = false, + var tags: String? = null, + var capabilities: String? = null, +) diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt new file mode 100644 index 00000000000..c0c840d5b2d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt @@ -0,0 +1,21 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import com.intellij.ui.components.JBTextArea +import java.awt.Dimension +import java.awt.Font + +internal class WrappingTextArea(text: String) : JBTextArea(text) { + init { + isEditable = false + isOpaque = false + lineWrap = true + wrapStyleWord = true + font = font.deriveFont(Font.PLAIN) + border = null + } + + override fun getMinimumSize(): Dimension = Dimension(50, 20) +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt index f1eb5506f5e..4b5701905b4 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AbstractExplorerTreeToolWindow.kt @@ -50,9 +50,10 @@ import javax.swing.tree.TreePath abstract class AbstractExplorerTreeToolWindow( treeStructure: AbstractTreeStructure, + initialTreeExpandDepth: Int = 2, ) : SimpleToolWindowPanel(true, true), DataProvider, Disposable { private val treeModel = StructureTreeModel(treeStructure, null, Invoker.forBackgroundPoolWithoutReadAction(this), this) - private val tree = Tree(AsyncTreeModel(treeModel, true, this)) + protected val tree = Tree(AsyncTreeModel(treeModel, true, this)) init { background = UIUtil.getTreeBackground() @@ -142,7 +143,7 @@ abstract class AbstractExplorerTreeToolWindow( redrawContent() - TreeUtil.expand(tree, 2) + TreeUtil.expand(tree, initialTreeExpandDepth) } abstract val actionPlace: String diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt index fc11a1afa5b..a6246182759 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/AwsToolkitExplorerToolWindow.kt @@ -55,6 +55,11 @@ class AwsToolkitExplorerToolWindow( if (!isQInstalled()) { put(Q_TAB_ID, { CodewhispererQToolWindow.getInstance(project) }) } + ToolkitToolWindowTab.EP_NAME.extensionList + .filter { it.enabled() } + .forEach { tab -> + put(tab.tabId, { tab.createContent(project) }) + } } init { diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt new file mode 100644 index 00000000000..dfe0ae89602 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/explorer/ToolkitToolWindowTab.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.explorer + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import java.awt.Component + +interface ToolkitToolWindowTab { + val tabId: String + fun createContent(project: Project): Component + fun enabled(): Boolean = true + + companion object { + val EP_NAME = ExtensionPointName("aws.toolkit.toolWindowTab") + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt new file mode 100644 index 00000000000..bb1dbe825b5 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt @@ -0,0 +1,27 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.system.CpuArch +import java.nio.file.Path +import java.nio.file.Paths + +internal fun getToolkitsCacheRoot(): Path = when { + SystemInfo.isWindows -> Paths.get(System.getenv("LOCALAPPDATA")) + SystemInfo.isMac -> Paths.get(System.getProperty("user.home"), "Library", "Caches") + else -> Paths.get(System.getProperty("user.home"), ".cache") +}.resolve("aws").resolve("toolkits") + +internal fun getCurrentOS(): String = when { + SystemInfo.isWindows -> "windows" + SystemInfo.isMac -> "darwin" + else -> "linux" +} + +internal fun getCurrentArchitecture(): String = when (CpuArch.CURRENT) { + CpuArch.X86_64 -> "x64" + CpuArch.ARM64 -> "arm64" + else -> "unknown" +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt new file mode 100644 index 00000000000..5db2016c42f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolver.kt @@ -0,0 +1,58 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.util.SystemInfo +import software.aws.toolkits.core.utils.debug +import software.aws.toolkits.core.utils.getLogger +import java.nio.file.Files +import java.nio.file.Path + +internal object NodeRuntimeResolver { + private val LOG = getLogger() + + /** + * Locates a Node.js executable with version >= minVersion. + * Uses IntelliJ's PathEnvironmentVariableUtil to search PATH. + * + * @return Path to valid Node.js executable, or null if not found + */ + fun resolve(minVersion: Int = 18): Path? { + val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + + return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) + .asSequence() + .map { it.toPath() } + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } + .firstNotNullOfOrNull { validateVersion(it, minVersion) } + } + + private fun validateVersion(path: Path, minVersion: Int): Path? = try { + val output = ExecUtil.execAndGetOutput( + com.intellij.execution.configurations.GeneralCommandLine(path.toString(), "--version"), + 5000 + ) + + if (output.exitCode == 0) { + val version = output.stdout.trim() + val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull() + + if (majorVersion != null && majorVersion >= minVersion) { + LOG.debug { "Node $version found at: $path" } + path.toAbsolutePath() + } else { + LOG.debug { "Node version < $minVersion at: $path (version: $version)" } + null + } + } else { + LOG.debug { "Failed to get version from node at: $path" } + null + } + } catch (e: Exception) { + LOG.debug(e) { "Failed to check version for node at: $path" } + null + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt new file mode 100644 index 00000000000..b7c88539efd --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspExtensionConfig.kt @@ -0,0 +1,15 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import software.aws.toolkits.jetbrains.AwsPlugin +import software.aws.toolkits.jetbrains.AwsToolkit + +object CfnLspExtensionConfig { + const val EXTENSION_NAME: String = AwsToolkit.TOOLKIT_PLUGIN_ID + val EXTENSION_VERSION: String = AwsToolkit.PLUGINS_INFO[AwsPlugin.TOOLKIT]?.version ?: "unknown" + const val ENCRYPTION_MODE = "JWT" + const val TELEMETRY_NOTIFICATION_GROUP_ID = "aws.cfn.telemetry" + const val INTRO_NOTIFICATION_GROUP_ID = "CloudFormation LSP Introduction" +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt new file mode 100644 index 00000000000..fb56dbe4cd9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt @@ -0,0 +1,132 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import org.eclipse.lsp4j.services.LanguageServer +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ClearStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateDeploymentParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DeleteChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeChangeSetResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeletionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeploymentStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetCapabilitiesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetParametersResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateArtifactsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetTemplateResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStackResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTypesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.UpdateCredentialsResult +import java.util.concurrent.CompletableFuture + +internal interface CfnLspServerProtocol : LanguageServer { + @JsonRequest("aws/credentials/iam/update") + fun updateIamCredentials(params: UpdateCredentialsParams): CompletableFuture + + @JsonRequest("aws/cfn/stacks") + fun listStacks(params: ListStacksParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/list") + fun listChangeSets(params: ListChangeSetsParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/types") + fun listResourceTypes(): CompletableFuture + + @JsonRequest("aws/cfn/resources/list") + fun listResources(params: ListResourcesParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/state") + fun getResourceState(params: ResourceStateParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/stackMgmtInfo") + fun getStackManagementInfo(resourceIdentifier: String): CompletableFuture + + @JsonRequest("aws/cfn/resources/search") + fun searchResource(params: SearchResourceParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/refresh") + fun refreshResources(params: RefreshResourcesParams): CompletableFuture + + @JsonRequest("aws/cfn/resources/list/remove") + fun removeResourceType(resourceType: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/validation/create") + fun createValidation(params: CreateValidationParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/validation/status") + fun getValidationStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/validation/status/describe") + fun describeValidationStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/describe") + fun describeChangeSet(params: DescribeChangeSetParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/delete") + fun deleteChangeSet(params: DeleteChangeSetParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/deletion/status") + fun getChangeSetDeletionStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/changeSet/deletion/status/describe") + fun describeChangeSetDeletionStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/deployment/create") + fun createDeployment(params: CreateDeploymentParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/deployment/status") + fun getDeploymentStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/deployment/status/describe") + fun describeDeploymentStatus(params: Identifiable): CompletableFuture + + @JsonRequest("aws/cfn/stack/parameters") + fun getParameters(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/capabilities") + fun getCapabilities(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/import/resources") + fun getTemplateResources(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/template/artifacts") + fun getTemplateArtifacts(uri: String): CompletableFuture + + @JsonRequest("aws/cfn/stack/describe") + fun describeStack(params: DescribeStackParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/resources") + fun getStackResources(params: GetStackResourcesParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/events") + fun getStackEvents(params: GetStackEventsParams): CompletableFuture + + @JsonRequest("aws/cfn/stack/events/clear") + fun clearStackEvents(params: ClearStackEventsParams): CompletableFuture +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt new file mode 100644 index 00000000000..fbbbc613bb2 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPrompter.kt @@ -0,0 +1,111 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.ide.BrowserUtil +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent +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 +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import software.aws.toolkits.jetbrains.settings.CfnLspSettings +import software.aws.toolkits.resources.AwsToolkitBundle.message + +private const val THIRTY_DAYS_MS = 30L * 24 * 60 * 60 * 1000 +private const val TELEMETRY_DOCS_URL = "https://github.com/aws-cloudformation/cloudformation-languageserver/tree/main/src/telemetry" + +@Service +@State(name = "cfnTelemetryPromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnTelemetryPromptState : PersistentStateComponent { + private var state = State() + + override fun getState(): State = state + override fun loadState(state: State) { this.state = state } + + var hasResponded: Boolean + get() = state.hasResponded + set(value) { state.hasResponded = value } + + var lastPromptDate: Long + get() = state.lastPromptDate + set(value) { state.lastPromptDate = value } + + class State( + var hasResponded: Boolean = false, + var lastPromptDate: Long = 0L, + ) + + companion object { + fun getInstance(): CfnTelemetryPromptState = service() + } +} + +internal class CfnTelemetryPrompter : ProjectActivity { + override suspend fun execute(project: Project) { + val promptState = CfnTelemetryPromptState.getInstance() + + if (promptState.hasResponded) return + + val now = System.currentTimeMillis() + if (promptState.lastPromptDate != 0L && now - promptState.lastPromptDate < THIRTY_DAYS_MS) return + + showPrompt(project) + } + + private fun showPrompt(project: Project) { + val notification = Notification( + CfnLspExtensionConfig.TELEMETRY_NOTIFICATION_GROUP_ID, + message("cloudformation.telemetry.prompt.title"), + message("cloudformation.telemetry.prompt.message"), + NotificationType.INFORMATION + ) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.allow")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice(telemetryEnabled = true, permanent = true) + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.not_now")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice(telemetryEnabled = false, permanent = false) + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.never")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + applyChoice(telemetryEnabled = false, permanent = true) + notification.expire() + } + }) + + notification.addAction(object : NotificationAction(message("cloudformation.telemetry.prompt.action.learn_more")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + BrowserUtil.browse(TELEMETRY_DOCS_URL) + } + }) + + notification.notify(project) + } + + private fun applyChoice(telemetryEnabled: Boolean, permanent: Boolean) { + val promptState = CfnTelemetryPromptState.getInstance() + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = permanent + promptState.lastPromptDate = System.currentTimeMillis() + + settings.isTelemetryEnabled = telemetryEnabled + settings.notifySettingsChanged() + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt new file mode 100644 index 00000000000..b7270cb71e7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/AuthProtocol.kt @@ -0,0 +1,13 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +internal data class UpdateCredentialsParams( + val data: String, + val encrypted: Boolean = true, +) + +internal data class UpdateCredentialsResult( + val success: Boolean, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt new file mode 100644 index 00000000000..3c165fda32b --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/ResourceProtocol.kt @@ -0,0 +1,82 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +import org.eclipse.lsp4j.CompletionItem +import org.eclipse.lsp4j.TextDocumentIdentifier + +// Resource Types +internal data class ResourceTypesResult( + val resourceTypes: List, +) + +// Resource Listing +internal data class ResourceRequest( + val resourceType: String, + val nextToken: String? = null, +) + +internal data class ListResourcesParams( + val resources: List? = null, +) + +internal data class ResourceSummary( + val typeName: String, + val resourceIdentifiers: List, + val nextToken: String? = null, +) + +internal data class ListResourcesResult( + val resources: List, +) + +internal enum class ResourceStatePurpose(val value: String) { + IMPORT("Import"), + CLONE("Clone"), +} + +internal data class ResourceSelection( + val resourceType: String, + val resourceIdentifiers: List, +) + +internal data class ResourceStateParams( + val textDocument: TextDocumentIdentifier, + val resourceSelections: List? = null, + val purpose: String, + val parentResourceType: String? = null, +) + +internal data class ResourceStateResult( + val completionItem: CompletionItem? = null, + val successfulImports: Map>, + val failedImports: Map>, + val warning: String? = null, +) + +internal data class SearchResourceParams( + val resourceType: String, + val identifier: String, +) + +internal data class SearchResourceResult( + val found: Boolean, + val resource: ResourceSummary? = null, +) + +internal data class RefreshResourcesParams( + val resources: List, +) + +internal data class RefreshResourcesResult( + val resources: List, +) + +internal data class ResourceStackManagementResult( + val physicalResourceId: String, + val managedByStack: Boolean? = null, + val stackName: String? = null, + val stackId: String? = null, + val error: String? = null, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt new file mode 100644 index 00000000000..70f212aef6d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackActionProtocol.kt @@ -0,0 +1,300 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +import com.google.gson.annotations.SerializedName + +internal data class Identifiable(val id: String) + +internal data class Parameter( + @SerializedName("ParameterKey") + val parameterKey: String, + + @SerializedName("ParameterValue") + val parameterValue: String, +) + +internal data class Tag( + @SerializedName("Key") + val key: String, + + @SerializedName("Value") + val value: String, +) + +internal data class ResourceToImport( + @SerializedName("ResourceType") + val resourceType: String, + + @SerializedName("LogicalResourceId") + val logicalResourceId: String, + + @SerializedName("ResourceIdentifier") + val resourceIdentifier: Map, +) + +@Suppress("unused") // All values required for Gson deserialization +internal enum class StackActionPhase { + @SerializedName("VALIDATION_STARTED") VALIDATION_STARTED, + + @SerializedName("VALIDATION_IN_PROGRESS") VALIDATION_IN_PROGRESS, + + @SerializedName("VALIDATION_COMPLETE") VALIDATION_COMPLETE, + + @SerializedName("VALIDATION_FAILED") VALIDATION_FAILED, + + @SerializedName("DEPLOYMENT_STARTED") DEPLOYMENT_STARTED, + + @SerializedName("DEPLOYMENT_IN_PROGRESS") DEPLOYMENT_IN_PROGRESS, + + @SerializedName("DEPLOYMENT_COMPLETE") DEPLOYMENT_COMPLETE, + + @SerializedName("DEPLOYMENT_FAILED") DEPLOYMENT_FAILED, + + @SerializedName("DELETION_STARTED") DELETION_STARTED, + + @SerializedName("DELETION_IN_PROGRESS") DELETION_IN_PROGRESS, + + @SerializedName("DELETION_COMPLETE") DELETION_COMPLETE, + + @SerializedName("DELETION_FAILED") DELETION_FAILED, +} + +@Suppress("unused") // All values required for Gson deserialization +internal enum class StackActionState { + @SerializedName("IN_PROGRESS") IN_PROGRESS, + + @SerializedName("SUCCESSFUL") SUCCESSFUL, + + @SerializedName("FAILED") FAILED, +} + +internal enum class DeploymentMode { + @SerializedName("REVERT_DRIFT") REVERT_DRIFT, +} + +internal data class CreateValidationParams( + val id: String, + val uri: String, + val stackName: String, + val parameters: List? = null, + val capabilities: List? = null, + val resourcesToImport: List? = null, + val keepChangeSet: Boolean? = null, + val onStackFailure: String? = null, + val includeNestedStacks: Boolean? = null, + val tags: List? = null, + val importExistingResources: Boolean? = null, + val deploymentMode: DeploymentMode? = null, + val s3Bucket: String? = null, + val s3Key: String? = null, +) + +internal data class CreateStackActionResult( + val id: String, + val changeSetName: String, + val stackName: String, +) + +internal data class GetStackActionStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + val changes: List? = null, +) + +internal data class ValidationDetail( + @SerializedName("ValidationName") + val validationName: String, + @SerializedName("LogicalId") + val logicalId: String? = null, + @SerializedName("ResourcePropertyPath") + val resourcePropertyPath: String? = null, + @SerializedName("Severity") + val severity: String, + @SerializedName("Message") + val message: String, +) + +internal data class DescribeValidationStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + val changes: List? = null, + @SerializedName("ValidationDetails") + val validationDetails: List? = null, + @SerializedName("FailureReason") + val failureReason: String? = null, + val deploymentMode: DeploymentMode? = null, +) + +internal data class StackChange( + val type: String? = null, + val resourceChange: ResourceChange? = null, +) + +internal data class ResourceChange( + val action: String? = null, + val logicalResourceId: String? = null, + val physicalResourceId: String? = null, + val resourceType: String? = null, + val replacement: String? = null, + val scope: List? = null, + val beforeContext: String? = null, + val afterContext: String? = null, + val resourceDriftStatus: String? = null, + val details: List? = null, +) + +internal data class ResourceChangeDetail( + @SerializedName("Target") + val target: ResourceTargetDefinition? = null, + @SerializedName("Evaluation") + val evaluation: String? = null, + @SerializedName("ChangeSource") + val changeSource: String? = null, + @SerializedName("CausingEntity") + val causingEntity: String? = null, +) + +internal data class ResourceTargetDefinition( + @SerializedName("Attribute") + val attribute: String? = null, + @SerializedName("Name") + val name: String? = null, + @SerializedName("RequiresRecreation") + val requiresRecreation: String? = null, + @SerializedName("Path") + val path: String? = null, + @SerializedName("BeforeValue") + val beforeValue: String? = null, + @SerializedName("AfterValue") + val afterValue: String? = null, + @SerializedName("AttributeChangeType") + val attributeChangeType: String? = null, + @SerializedName("Drift") + val drift: DriftInfo? = null, + @SerializedName("LiveResourceDrift") + val liveResourceDrift: DriftInfo? = null, +) + +internal data class DriftInfo( + @SerializedName("PreviousValue") + val previousValue: String? = null, + @SerializedName("ActualValue") + val actualValue: String? = null, +) + +internal data class DescribeChangeSetParams( + val changeSetName: String, + val stackName: String, +) + +internal data class DescribeChangeSetResult( + val changeSetName: String, + val stackName: String, + val status: String, + val creationTime: String? = null, + val description: String? = null, + val changes: List? = null, + val deploymentMode: DeploymentMode? = null, +) + +// Deployment + +internal data class CreateDeploymentParams( + val id: String, + val changeSetName: String, + val stackName: String, +) + +internal data class DeploymentEvent( + @SerializedName("LogicalResourceId") + val logicalResourceId: String? = null, + @SerializedName("ResourceType") + val resourceType: String? = null, + @SerializedName("ResourceStatus") + val resourceStatus: String? = null, + @SerializedName("ResourceStatusReason") + val resourceStatusReason: String? = null, +) + +internal data class DescribeDeploymentStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + val changes: List? = null, + @SerializedName("DeploymentEvents") + val deploymentEvents: List? = null, + @SerializedName("FailureReason") + val failureReason: String? = null, +) + +// Change set deletion + +internal data class DeleteChangeSetParams( + val id: String, + val changeSetName: String, + val stackName: String, +) + +internal data class DescribeDeletionStatusResult( + val id: String, + val phase: StackActionPhase, + val state: StackActionState, + @SerializedName("FailureReason") + val failureReason: String? = null, +) + +// Template analysis + +internal data class TemplateParameter( + val name: String, + @SerializedName("Type") + val type: String? = null, + @SerializedName("Default") + val default: Any? = null, + @SerializedName("Description") + val description: String? = null, + @SerializedName("AllowedValues") + val allowedValues: List? = null, + @SerializedName("AllowedPattern") + val allowedPattern: String? = null, + @SerializedName("MinLength") + val minLength: Int? = null, + @SerializedName("MaxLength") + val maxLength: Int? = null, + @SerializedName("MinValue") + val minValue: Number? = null, + @SerializedName("MaxValue") + val maxValue: Number? = null, +) + +internal data class GetParametersResult( + val parameters: List, +) + +internal data class GetCapabilitiesResult( + val capabilities: List, +) + +internal data class TemplateResource( + val logicalId: String, + val type: String, + val primaryIdentifierKeys: List? = null, + val primaryIdentifier: Map? = null, +) + +internal data class GetTemplateResourcesResult( + val resources: List, +) + +internal data class Artifact( + val resourceType: String, + val filePath: String, +) + +internal data class GetTemplateArtifactsResult( + val artifacts: List, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt new file mode 100644 index 00000000000..806d6ec8c88 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt @@ -0,0 +1,162 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.protocol + +import com.google.gson.annotations.SerializedName + +internal data class ListStacksParams( + val statusToExclude: List? = null, + val loadMore: Boolean = false, +) + +internal data class StackSummary( + @SerializedName("StackName") val stackName: String? = null, + @SerializedName("StackId") val stackId: String? = null, + @SerializedName("StackStatus") val stackStatus: String? = null, + @SerializedName("CreationTime") val creationTime: String? = null, + @SerializedName("LastUpdateTime") val lastUpdatedTime: String? = null, + @SerializedName("TemplateDescription") val templateDescription: String? = null, +) + +internal data class ListStacksResult( + val stacks: List, + val nextToken: String? = null, +) + +internal data class ListChangeSetsParams( + val stackName: String, + val nextToken: String? = null, +) + +internal data class ChangeSetInfo( + val changeSetName: String, + val status: String, + val creationTime: String? = null, + val description: String? = null, +) + +internal data class ListChangeSetsResult( + val changeSets: List, + val nextToken: String? = null, +) + +internal data class DescribeStackParams( + val stackName: String, +) + +internal data class DescribeStackResult( + val stack: StackDetail?, +) + +internal data class StackDetail( + @SerializedName("StackName") + val stackName: String, + @SerializedName("StackId") + val stackId: String, + @SerializedName("StackStatus") + val stackStatus: String, + @SerializedName("StackStatusReason") + val stackStatusReason: String? = null, + @SerializedName("CreationTime") + val creationTime: String? = null, + @SerializedName("LastUpdatedTime") + val lastUpdatedTime: String? = null, + @SerializedName("Description") + val description: String? = null, + @SerializedName("Outputs") + val outputs: List? = null, + @SerializedName("Parameters") + val parameters: List? = null, + @SerializedName("Tags") + val tags: List? = null, +) + +internal data class StackOutput( + @SerializedName("OutputKey") + val outputKey: String, + @SerializedName("OutputValue") + val outputValue: String, + @SerializedName("Description") + val description: String? = null, + @SerializedName("ExportName") + val exportName: String? = null, +) + +// Stack Resources Protocol +internal data class GetStackResourcesParams( + val stackName: String, + val nextToken: String? = null, +) + +internal data class StackResourceSummary( + @SerializedName("LogicalResourceId") + val logicalResourceId: String, + @SerializedName("PhysicalResourceId") + val physicalResourceId: String?, + @SerializedName("ResourceType") + val resourceType: String, + @SerializedName("ResourceStatus") + val resourceStatus: String, + @SerializedName("Timestamp") + val timestamp: String?, +) + +internal data class ListStackResourcesResult( + val resources: List, + val nextToken: String? = null, +) + +// Stack Events Protocol +internal data class GetStackEventsParams( + val stackName: String, + val nextToken: String? = null, + val refresh: Boolean? = null, +) + +internal data class GetStackEventsResult( + val events: List, + val nextToken: String? = null, + val gapDetected: Boolean? = null, +) + +internal data class StackEvent( + @SerializedName("StackId") + val stackId: String? = null, + @SerializedName("EventId") + val eventId: String? = null, + @SerializedName("StackName") + val stackName: String? = null, + @SerializedName("LogicalResourceId") + val logicalResourceId: String? = null, + @SerializedName("PhysicalResourceId") + val physicalResourceId: String? = null, + @SerializedName("ResourceType") + val resourceType: String? = null, + @SerializedName("Timestamp") + val timestamp: String? = null, + @SerializedName("ResourceStatus") + val resourceStatus: String? = null, + @SerializedName("ResourceStatusReason") + val resourceStatusReason: String? = null, + @SerializedName("ResourceProperties") + val resourceProperties: String? = null, + @SerializedName("ClientRequestToken") + val clientRequestToken: String? = null, + @SerializedName("OperationId") + val operationId: String? = null, + @SerializedName("HookType") + val hookType: String? = null, + @SerializedName("HookStatus") + val hookStatus: String? = null, + @SerializedName("HookStatusReason") + val hookStatusReason: String? = null, + @SerializedName("HookInvocationPoint") + val hookInvocationPoint: String? = null, + @SerializedName("HookFailureMode") + val hookFailureMode: String? = null, +) + +internal data class ClearStackEventsParams( + val stackName: String, +) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt new file mode 100644 index 00000000000..5175fcebc56 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspException.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +internal class CfnLspException( + message: String, + val errorCode: ErrorCode, + cause: Throwable? = null, +) : Exception(message, cause) { + + enum class ErrorCode { + MANIFEST_FETCH_FAILED, + NO_COMPATIBLE_VERSION, + DOWNLOAD_FAILED, + EXTRACTION_FAILED, + NODE_NOT_FOUND, + HASH_VERIFICATION_FAILED, + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt new file mode 100644 index 00000000000..db098b4f677 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt @@ -0,0 +1,234 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.ide.util.PropertiesComponent +import software.aws.toolkits.core.utils.debug +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.lsp.getToolkitsCacheRoot +import software.aws.toolkits.jetbrains.utils.ZipDecompressor +import software.aws.toolkits.resources.AwsToolkitBundle.message +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import kotlin.io.path.isDirectory + +internal class CfnLspInstaller( + private val storageDir: Path = defaultStorageDir(), + private val manifestAdapter: GitHubManifestAdapter = GitHubManifestAdapter(CfnLspEnvironment.PROD), +) { + private val httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(java.time.Duration.ofSeconds(30)) + .build() + + private val versionRange = SemVerRange.parse(CfnLspServerConfig.SUPPORTED_VERSION_RANGE) + + fun getServerPath(): Path { + val release = try { + manifestAdapter.getLatestRelease().also { saveManifestCache() } + } catch (e: Exception) { + LOG.warn(e) { "Failed to fetch manifest, trying cached manifest" } + tryFromCachedManifest() ?: run { + LOG.warn { "No cached manifest, trying cached server" } + return findCachedServer() ?: throw CfnLspException( + message("cloudformation.lsp.error.manifest_failed"), + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED, + e + ) + } + } + + val versionDir = storageDir.resolve(release.version) + val serverPath = versionDir.resolve(CfnLspServerConfig.SERVER_FILE) + + return if (Files.exists(serverPath)) { + LOG.info { "Using cached CloudFormation LSP ${release.version}" } + serverPath + } else { + downloadAndInstall(release).also { cleanupOldVersions(release.version) } + } + } + + private fun tryFromCachedManifest(): ServerRelease? { + val cached = loadManifestCache() ?: return null + return try { + LOG.debug { "Using cached manifest for offline mode" } + manifestAdapter.parseManifest(cached) + } catch (e: Exception) { + LOG.warn(e) { "Failed to parse cached manifest" } + null + } + } + + /** + * Finds the highest compatible cached server version. + * Uses semver comparison to pick the best available fallback. + */ + private fun findCachedServer(): Path? { + if (!Files.exists(storageDir)) return null + + return Files.list(storageDir).use { stream -> + stream.toList() + .filter { it.isDirectory() } + .filter { Files.exists(it.resolve(CfnLspServerConfig.SERVER_FILE)) } + .mapNotNull { dir -> SemVer.parse(dir.fileName.toString())?.let { dir to it } } + .filter { (_, ver) -> versionRange.satisfiedBy(ver) } + .maxByOrNull { (_, ver) -> ver } + ?.first + ?.resolve(CfnLspServerConfig.SERVER_FILE) + ?.also { LOG.info { "Using fallback cached server: $it" } } + } + } + + private fun downloadAndInstall(release: ServerRelease): Path { + LOG.info { "Downloading CloudFormation LSP ${release.version}" } + + val zipBytes = try { + downloadAsset(release.downloadUrl) + } catch (e: Exception) { + LOG.error(e) { "Failed to download CloudFormation LSP" } + throw CfnLspException( + message("cloudformation.lsp.error.download_failed"), + CfnLspException.ErrorCode.DOWNLOAD_FAILED, + e + ) + } + + // Verify hash if available + if (release.hashes.isNotEmpty()) { + verifyHash(zipBytes, release.hashes) + } + + val targetDir = storageDir.resolve(release.version) + try { + Files.createDirectories(targetDir) + ZipDecompressor(zipBytes).use { it.extract(targetDir.toFile()) } + } catch (e: Exception) { + LOG.error(e) { "Failed to extract CloudFormation LSP" } + throw CfnLspException( + message("cloudformation.lsp.error.extraction_failed"), + CfnLspException.ErrorCode.EXTRACTION_FAILED, + e + ) + } + + val serverPath = targetDir.resolve(CfnLspServerConfig.SERVER_FILE) + LOG.info { "CloudFormation LSP installed to: $serverPath" } + return serverPath + } + + private fun verifyHash(data: ByteArray, expectedHashes: List) { + for (expected in expectedHashes) { + val (algorithm, hash) = parseHashString(expected) ?: continue + val computed = computeHash(data, algorithm) + if (computed.equals(hash, ignoreCase = true)) { + LOG.debug { "Hash verification passed ($algorithm)" } + return + } + LOG.warn { "Hash mismatch for $algorithm: expected $hash, got $computed" } + } + if (expectedHashes.isNotEmpty()) { + throw CfnLspException( + message("cloudformation.lsp.error.hash_mismatch"), + CfnLspException.ErrorCode.HASH_VERIFICATION_FAILED + ) + } + } + + /** + * Removes old versions, keeping the current version and one compatible fallback. + */ + private fun cleanupOldVersions(currentVersion: String) { + if (!Files.exists(storageDir)) return + + try { + val dirs = Files.list(storageDir).use { stream -> + stream.filter { it.isDirectory() }.toList() + } + + // Keep the highest compatible version other than current as fallback + val fallbackDir = dirs + .filter { it.fileName.toString() != currentVersion } + .mapNotNull { dir -> SemVer.parse(dir.fileName.toString())?.let { dir to it } } + .filter { (_, ver) -> versionRange.satisfiedBy(ver) } + .maxByOrNull { (_, ver) -> ver } + ?.first + + val keep = setOfNotNull(currentVersion, fallbackDir?.fileName?.toString()) + + dirs.filter { it.fileName.toString() !in keep } + .forEach { oldDir -> + LOG.debug { "Removing old LSP version: ${oldDir.fileName}" } + oldDir.toFile().deleteRecursively() + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to cleanup old LSP versions" } + } + } + + private fun downloadAsset(url: String): ByteArray { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(java.time.Duration.ofMinutes(5)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()) + + if (response.statusCode() != 200) { + throw RuntimeException("HTTP ${response.statusCode()}") + } + + return response.body() + } + + private fun saveManifestCache() { + val json = manifestAdapter.getCachedManifest() ?: return + try { + PropertiesComponent.getInstance().setValue(MANIFEST_CACHE_KEY, json) + } catch (e: Exception) { + LOG.debug { "Failed to save manifest cache: ${e.message}" } + } + } + + private fun loadManifestCache(): String? = try { + PropertiesComponent.getInstance().getValue(MANIFEST_CACHE_KEY) + } catch (e: Exception) { + LOG.debug { "Failed to load manifest cache: ${e.message}" } + null + } + + companion object { + private val LOG = getLogger() + private const val MANIFEST_CACHE_KEY = "aws.cloudformation.lsp.manifest" + + fun defaultStorageDir(): Path = getToolkitsCacheRoot().resolve("cloudformation-lsp") + + internal fun parseHashString(hashString: String): Pair? { + // Format: "sha256:abc123..." or "sha384:abc123..." + val parts = hashString.split(":", limit = 2) + return if (parts.size == 2) parts[0] to parts[1] else null + } + + internal fun computeHash(data: ByteArray, algorithm: String): String { + val digestAlgorithm = when (algorithm.lowercase()) { + "sha256" -> "SHA-256" + "sha384" -> "SHA-384" + "sha512" -> "SHA-512" + else -> algorithm.uppercase() + } + return MessageDigest.getInstance(digestAlgorithm) + .digest(data) + .joinToString("") { "%02x".format(it) } + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt new file mode 100644 index 00000000000..d47845e78de --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt @@ -0,0 +1,17 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +internal object CfnLspServerConfig { + const val SERVER_FILE = "cfn-lsp-server-standalone.js" + const val GITHUB_OWNER = "aws-cloudformation" + const val GITHUB_REPO = "cloudformation-languageserver" + + /** Semver range constraint for compatible language server versions. */ + const val SUPPORTED_VERSION_RANGE = "<2.0.0" +} + +internal enum class CfnLspEnvironment { + ALPHA, BETA, PROD +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt new file mode 100644 index 00000000000..9458cfc78ee --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt @@ -0,0 +1,168 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.util.SystemInfo +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.lsp.getCurrentArchitecture +import software.aws.toolkits.jetbrains.core.lsp.getCurrentOS +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +internal data class ManifestVersion( + val serverVersion: String, + val latest: Boolean = false, + val isDelisted: Boolean = false, + val targets: List, +) + +internal data class ManifestTarget( + val platform: String, + val arch: String, + val nodejs: String? = null, + val contents: List, +) + +internal data class ManifestContent( + val filename: String, + val url: String, + val hashes: List = emptyList(), + val bytes: Long, +) + +internal data class ServerRelease( + val version: String, + val downloadUrl: String, + val filename: String, + val size: Long, + val hashes: List = emptyList(), +) + +internal class GitHubManifestAdapter( + private val environment: CfnLspEnvironment, + private val versionRange: SemVerRange = SemVerRange.parse(CfnLspServerConfig.SUPPORTED_VERSION_RANGE), + private val legacyLinuxDetector: LegacyLinuxDetector = LegacyLinuxDetector(), +) { + private val httpClient = HttpClient.newBuilder().build() + private val mapper = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + @Volatile + private var cachedManifestJson: String? = null + + fun getLatestRelease(): ServerRelease { + val manifestJson = fetchManifestJson() + cachedManifestJson = manifestJson + return parseManifest(manifestJson) + } + + internal fun parseManifest(json: String): ServerRelease { + val root = mapper.readTree(json) + val envKey = environment.name.lowercase() + var versions: List = mapper.readValue(root.get(envKey).toString()) + + if (SystemInfo.isLinux && legacyLinuxDetector.useLegacyLinux()) { + LOG.info { "Legacy Linux environment detected, remapping to linuxglib2.28" } + versions = remapLegacyLinux(versions) + } + + LOG.info { + "Candidate versions for $environment: ${versions.joinToString { v -> + "${v.serverVersion}[${v.targets.joinToString(",") { "${it.platform}-${it.arch}" }}]" + }}" + } + + val version = latestCompatibleVersion(versions) + + val platform = getEffectivePlatform() + val arch = getCurrentArchitecture() + + val target = version.targets.firstOrNull { it.platform == platform && it.arch == arch } + ?: error("No target found for $platform-$arch") + + val content = target.contents.firstOrNull() + ?: error("No content found for $platform-$arch") + + LOG.info { "Selected ${version.serverVersion} for $platform-$arch" } + + return ServerRelease( + version = version.serverVersion, + downloadUrl = content.url, + filename = content.filename, + size = content.bytes, + hashes = content.hashes + ) + } + + /** + * Selects the best compatible version from the manifest. + * Prefers the version marked as "latest" if it's compatible, otherwise falls back to + * the highest compatible version by semver. + */ + private fun latestCompatibleVersion(versions: List): ManifestVersion { + val compatible = versions + .filter { !it.isDelisted } + .mapNotNull { v -> SemVer.parse(v.serverVersion)?.let { v to it } } + .filter { (_, semver) -> versionRange.satisfiedBy(semver) } + + // Prefer the version explicitly marked as latest + val markedLatest = compatible.firstOrNull { (v, _) -> v.latest } + if (markedLatest != null) return markedLatest.first + + // Fall back to the highest by semver + return compatible + .maxByOrNull { (_, semver) -> semver } + ?.first + ?: error("No compatible version found for range ${CfnLspServerConfig.SUPPORTED_VERSION_RANGE} in $environment") + } + + private fun fetchManifestJson(): String { + val url = "https://raw.githubusercontent.com/${CfnLspServerConfig.GITHUB_OWNER}/${CfnLspServerConfig.GITHUB_REPO}/main/assets/release-manifest.json" + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) { + error("Manifest fetch error: ${response.statusCode()}") + } + return response.body() + } + + fun getCachedManifest(): String? = cachedManifestJson + + private fun getEffectivePlatform(): String { + if (SystemInfo.isLinux && legacyLinuxDetector.useLegacyLinux()) { + return LEGACY_LINUX_PLATFORM + } + return getCurrentOS() + } + + companion object { + private val LOG = getLogger() + private const val LEGACY_LINUX_PLATFORM = "linuxglib2.28" + + internal fun remapLegacyLinux(versions: List): List = + versions.map { version -> + val hasLegacy = version.targets.any { it.platform == LEGACY_LINUX_PLATFORM } + if (!hasLegacy) { + LOG.warn { "No legacy Linux build for ${version.serverVersion}" } + return@map version + } + version.copy( + targets = version.targets + .filter { it.platform != "linux" } + .map { if (it.platform == LEGACY_LINUX_PLATFORM) it.copy(platform = "linux") else it } + ) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt new file mode 100644 index 00000000000..a5919135f54 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetector.kt @@ -0,0 +1,124 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.intellij.openapi.util.SystemInfo +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import java.io.File +import java.util.concurrent.TimeUnit + +/** + * Detects legacy Linux environments that require older glibc-compatible builds. + */ +internal class LegacyLinuxDetector { + private val glibcxxThreshold = listOf(3, 4, 29) // GLIBCXX_3.4.29 + + fun useLegacyLinux(): Boolean { + if (!SystemInfo.isLinux) return false + + // Check for Snap environment + if (System.getenv("SNAP") != null) { + LOG.info { "Snap environment detected" } + return true + } + + val maxVersion = getMaxGlibcxxVersion() ?: return false + val isLegacy = compareVersions(maxVersion, glibcxxThreshold) < 0 + + if (isLegacy) { + LOG.info { "GLIBCXX $maxVersion < 3.4.29, using legacy Linux build" } + } + return isLegacy + } + + internal fun getMaxGlibcxxVersion(): List? { + val libPath = findLibStdCpp() ?: return null + + val output = extractGlibcxxStringsUsingStringsCommand(libPath) + ?: extractGlibcxxStringsFromBinaryFile(libPath) + if (output == null) { + LOG.warn { "Failed to read GLIBCXX versions from $libPath" } + return null + } + + return parseGlibcxxVersions(output).maxWithOrNull(::compareVersions) + } + + private fun extractGlibcxxStringsUsingStringsCommand(libPath: String): String? = try { + val process = ProcessBuilder("strings", libPath) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + if (process.waitFor(10, TimeUnit.SECONDS) && output.contains("GLIBCXX")) output else null + } catch (_: Exception) { + LOG.info { "strings command failed, trying binary read fallback" } + null + } + + private fun extractGlibcxxStringsFromBinaryFile(libPath: String): String? = try { + val bytes = File(libPath).readBytes() + val content = String(bytes, Charsets.ISO_8859_1) + val matches = Regex("""GLIBCXX_\d+\.\d+(?:\.\d+)?""").findAll(content) + matches.joinToString("\n") { it.value }.ifEmpty { null } + } catch (e: Exception) { + LOG.warn(e) { "Failed to read binary at $libPath" } + null + } + + internal fun parseGlibcxxVersions(output: String): List> = + Regex("""GLIBCXX_(\d+\.\d+(?:\.\d+)?)""") + .findAll(output) + .map { it.groupValues[1].split(".").map(String::toInt) } + .toList() + + private fun findLibStdCpp(): String? { + // Try ldconfig first (most reliable on standard Linux) + findLibStdCppUsingLdconfig()?.let { return it } + + // Fallback to common paths + val commonPaths = listOf( + "/usr/lib/x86_64-linux-gnu/libstdc++.so.6", + "/usr/lib64/libstdc++.so.6", + "/usr/lib/libstdc++.so.6", + "/lib/x86_64-linux-gnu/libstdc++.so.6", + ) + return commonPaths.firstOrNull { File(it).exists() } + } + + private fun findLibStdCppUsingLdconfig(): String? = try { + val process = ProcessBuilder("/sbin/ldconfig", "-p") + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + if (!process.waitFor(5, TimeUnit.SECONDS)) { + null + } else { + // Parse: "libstdc++.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libstdc++.so.6" + output.lineSequence() + .filter { it.contains("libstdc++.so.6") } + .firstNotNullOfOrNull { line -> + Regex("""=>\s+(.+)$""").find(line)?.groupValues?.get(1)?.trim() + } + } + } catch (_: Exception) { + null + } + + companion object { + private val LOG = getLogger() + + internal fun compareVersions(a: List, b: List): Int { + for (i in 0 until maxOf(a.size, b.size)) { + val partA = a.getOrElse(i) { 0 } + val partB = b.getOrElse(i) { 0 } + if (partA != partB) return partA.compareTo(partB) + } + return 0 + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt new file mode 100644 index 00000000000..4f1148ac147 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVer.kt @@ -0,0 +1,101 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +/** + * Minimal semver implementation for comparing language server versions. + * Handles formats: "1.4.0", "v1.4.0", "1.4.0-beta" + */ +internal data class SemVer( + val major: Int, + val minor: Int, + val patch: Int, + val prerelease: List = emptyList(), +) : Comparable { + + override fun compareTo(other: SemVer): Int { + major.compareTo(other.major).let { if (it != 0) return it } + minor.compareTo(other.minor).let { if (it != 0) return it } + patch.compareTo(other.patch).let { if (it != 0) return it } + + // No prerelease > has prerelease (1.0.0 > 1.0.0-beta) + if (prerelease.isEmpty() && other.prerelease.isNotEmpty()) return 1 + if (prerelease.isNotEmpty() && other.prerelease.isEmpty()) return -1 + + // Compare prerelease identifiers left to right + for (i in 0 until maxOf(prerelease.size, other.prerelease.size)) { + val thisId = prerelease.getOrNull(i) + val otherId = other.prerelease.getOrNull(i) + if (thisId == null) return -1 // fewer fields = lower precedence + if (otherId == null) return 1 + val thisNum = thisId.toIntOrNull() + val otherNum = otherId.toIntOrNull() + val result = when { + thisNum != null && otherNum != null -> thisNum.compareTo(otherNum) + thisNum != null -> -1 // numeric < string + otherNum != null -> 1 + else -> thisId.compareTo(otherId) + } + if (result != 0) return result + } + return 0 + } + + companion object { + fun parse(version: String): SemVer? { + val cleaned = version.removePrefix("v") + val hyphenIdx = cleaned.indexOf('-') + val core = if (hyphenIdx >= 0) cleaned.substring(0, hyphenIdx) else cleaned + val prePart = if (hyphenIdx >= 0) cleaned.substring(hyphenIdx + 1) else null + + val parts = core.split('.') + if (parts.size != 3) return null + + val major = parts[0].toIntOrNull() ?: return null + val minor = parts[1].toIntOrNull() ?: return null + val patch = parts[2].toIntOrNull() ?: return null + val prerelease = prePart?.split('-').orEmpty() + + return SemVer(major, minor, patch, prerelease) + } + } +} + +/** + * Simple version range supporting constraints like "<2.0.0". + * Supports: =X.Y.Z, >X.Y.Z + */ +internal class SemVerRange private constructor( + private val constraints: List, +) { + private data class Constraint(val op: String, val version: SemVer) + + fun satisfiedBy(version: SemVer): Boolean = + constraints.all { constraint -> + val coreVersion = SemVer(version.major, version.minor, version.patch) + when (constraint.op) { + "<" -> coreVersion < constraint.version + "<=" -> coreVersion <= constraint.version + ">" -> coreVersion > constraint.version + ">=" -> coreVersion >= constraint.version + else -> false + } + } + + companion object { + private val CONSTRAINT_PATTERN = Regex("""(<=?|>=?)\s*(\S+)""") + + fun parse(range: String): SemVerRange { + val constraints = CONSTRAINT_PATTERN.findAll(range).map { match -> + val op = match.groupValues[1] + val ver = SemVer.parse(match.groupValues[2]) + ?: error("Invalid version in range: ${match.groupValues[2]}") + Constraint(op, ver) + }.toList() + + require(constraints.isNotEmpty()) { "Invalid version range: $range" } + return SemVerRange(constraints) + } + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt new file mode 100644 index 00000000000..83ab3d9e38e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettings.kt @@ -0,0 +1,199 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.application.ApplicationManager +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 +import com.intellij.util.messages.Topic + +fun interface CfnLspSettingsChangeListener { + fun settingsChanged() + + companion object { + val TOPIC = Topic.create("CFN LSP Settings Changed", CfnLspSettingsChangeListener::class.java) + } +} + +@Service +@State(name = "cfnLspSettings", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnLspSettings : PersistentStateComponent { + private var state = State() + + override fun getState(): State = state + override fun loadState(state: State) { this.state = state } + + fun notifySettingsChanged() { + ApplicationManager.getApplication().messageBus + .syncPublisher(CfnLspSettingsChangeListener.TOPIC) + .settingsChanged() + } + + var nodeRuntimePath: String + get() = state.nodeRuntimePath + set(value) { state.nodeRuntimePath = value } + + var isTelemetryEnabled: Boolean + get() = state.isTelemetryEnabled + set(value) { state.isTelemetryEnabled = value } + + var isHoverEnabled: Boolean + get() = state.isHoverEnabled + set(value) { state.isHoverEnabled = value } + + var isCompletionEnabled: Boolean + get() = state.isCompletionEnabled + set(value) { state.isCompletionEnabled = value } + + var maxCompletions: Int + get() = state.maxCompletions + set(value) { state.maxCompletions = value } + + // CFN-Lint settings + var isCfnLintEnabled: Boolean + get() = state.isCfnLintEnabled + set(value) { state.isCfnLintEnabled = value } + + var cfnLintLintOnChange: Boolean + get() = state.cfnLintLintOnChange + set(value) { state.cfnLintLintOnChange = value } + + var cfnLintDelayMs: Int + get() = state.cfnLintDelayMs + set(value) { state.cfnLintDelayMs = value } + + var cfnLintPath: String + get() = state.cfnLintPath + set(value) { state.cfnLintPath = value } + + var cfnLintIgnoreChecks: String + get() = state.cfnLintIgnoreChecks + set(value) { state.cfnLintIgnoreChecks = value } + + var cfnLintIncludeChecks: String + get() = state.cfnLintIncludeChecks + set(value) { state.cfnLintIncludeChecks = value } + + var cfnLintIncludeExperimental: Boolean + get() = state.cfnLintIncludeExperimental + set(value) { state.cfnLintIncludeExperimental = value } + + var cfnLintCustomRules: String + get() = state.cfnLintCustomRules + set(value) { state.cfnLintCustomRules = value } + + var cfnLintAppendRules: String + get() = state.cfnLintAppendRules + set(value) { state.cfnLintAppendRules = value } + + var cfnLintOverrideSpec: String + get() = state.cfnLintOverrideSpec + set(value) { state.cfnLintOverrideSpec = value } + + var cfnLintRegistrySchemas: String + get() = state.cfnLintRegistrySchemas + set(value) { state.cfnLintRegistrySchemas = value } + + // CFN-Guard settings + var isCfnGuardEnabled: Boolean + get() = state.isCfnGuardEnabled + set(value) { state.isCfnGuardEnabled = value } + + var cfnGuardValidateOnChange: Boolean + get() = state.cfnGuardValidateOnChange + set(value) { state.cfnGuardValidateOnChange = value } + + var cfnGuardEnabledRulePacks: String + get() = state.cfnGuardEnabledRulePacks + set(value) { state.cfnGuardEnabledRulePacks = value } + + var cfnGuardRulesFile: String + get() = state.cfnGuardRulesFile + set(value) { state.cfnGuardRulesFile = value } + + data class State( + var nodeRuntimePath: String = "", + var isTelemetryEnabled: Boolean = false, + var isHoverEnabled: Boolean = true, + var isCompletionEnabled: Boolean = true, + var maxCompletions: Int = 100, + // CFN-Lint + var isCfnLintEnabled: Boolean = true, + var cfnLintLintOnChange: Boolean = true, + var cfnLintDelayMs: Int = 3000, + var cfnLintPath: String = "", + var cfnLintIgnoreChecks: String = "", + var cfnLintIncludeChecks: String = "I", + var cfnLintIncludeExperimental: Boolean = false, + var cfnLintCustomRules: String = "", + var cfnLintAppendRules: String = "", + var cfnLintOverrideSpec: String = "", + var cfnLintRegistrySchemas: String = "", + // CFN-Guard + var isCfnGuardEnabled: Boolean = true, + var cfnGuardValidateOnChange: Boolean = true, + var cfnGuardEnabledRulePacks: String = "wa-Security-Pillar", + var cfnGuardRulesFile: String = "", + ) + + companion object { + fun getInstance(): CfnLspSettings = service() + + val GUARD_RULE_PACKS = listOf( + "ABS-CCIGv2-Material", + "ABS-CCIGv2-Standard", + "acsc-essential-8", + "acsc-ism", + "apra-cpg-234", + "bnm-rmit", + "cis-aws-benchmark-level-1", + "cis-aws-benchmark-level-2", + "cis-critical-security-controls-v8-ig1", + "cis-critical-security-controls-v8-ig2", + "cis-critical-security-controls-v8-ig3", + "cis-top-20", + "cisa-ce", + "cmmc-level-1", + "cmmc-level-2", + "cmmc-level-3", + "cmmc-level-4", + "cmmc-level-5", + "enisa-cybersecurity-guide-for-smes", + "ens-high", + "ens-low", + "ens-medium", + "FDA-21CFR-Part-11", + "FedRAMP-Low", + "FedRAMP-Moderate", + "ffiec", + "hipaa-security", + "K-ISMS", + "mas-notice-655", + "mas-trmg", + "nbc-trmg", + "ncsc-cafv3", + "ncsc", + "nerc", + "nist-1800-25", + "nist-800-171", + "nist-800-172", + "nist-800-181", + "nist-csf", + "nist-privacy-framework", + "NIST800-53Rev4", + "NIST800-53Rev5", + "nzism", + "PCI-DSS-3-2-1", + "rbi-bcsf-ucb", + "rbi-md-itf", + "us-nydfs", + "wa-Reliability-Pillar", + "wa-Security-Pillar" + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt new file mode 100644 index 00000000000..1e8f8a5ee76 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/settings/CfnLspSettingsConfigurable.kt @@ -0,0 +1,181 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory +import com.intellij.openapi.options.BoundConfigurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.ui.CheckBoxList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.builder.bindIntText +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI +import software.aws.toolkits.resources.AwsToolkitBundle.message + +internal class CfnLspSettingsConfigurable : BoundConfigurable(message("cloudformation.settings.title")), SearchableConfigurable { + private val settings = CfnLspSettings.getInstance() + + override fun createPanel() = panel { + group(message("cloudformation.settings.general.group")) { + row(message("cloudformation.settings.node.path")) { + textFieldWithBrowseButton( + FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle(message("cloudformation.settings.node.path.browse")) + ).bindText(settings::nodeRuntimePath).columns(30).comment(message("cloudformation.settings.node.path.comment")) + } + row { + checkBox(message("cloudformation.settings.telemetry.enable")).bindSelected(settings::isTelemetryEnabled) + } + } + + group(message("cloudformation.settings.hover.group")) { + row { + checkBox(message("cloudformation.settings.hover.enable")).bindSelected(settings::isHoverEnabled) + } + } + + group(message("cloudformation.settings.completion.group")) { + row { + checkBox(message("cloudformation.settings.completion.enable")).bindSelected(settings::isCompletionEnabled) + } + row(message("cloudformation.settings.completion.max")) { + intTextField(1..1000).bindIntText(settings::maxCompletions).columns(6) + } + } + + collapsibleGroup(message("cloudformation.settings.cfnlint.group")) { + row { + checkBox(message("cloudformation.settings.cfnlint.enable")).bindSelected(settings::isCfnLintEnabled) + } + row { + checkBox(message("cloudformation.settings.cfnlint.lintOnChange")).bindSelected(settings::cfnLintLintOnChange) + } + row(message("cloudformation.settings.cfnlint.delayMs")) { + intTextField(0..60000).bindIntText(settings::cfnLintDelayMs).columns(6).comment(message("cloudformation.settings.cfnlint.delayMs.comment")) + } + row(message("cloudformation.settings.cfnlint.path")) { + textField().bindText(settings::cfnLintPath).columns(30).comment(message("cloudformation.settings.cfnlint.path.comment")) + } + row { + checkBox(message("cloudformation.settings.cfnlint.includeExperimental")).bindSelected(settings::cfnLintIncludeExperimental) + } + row(message("cloudformation.settings.cfnlint.ignoreChecks")) { + textField().bindText(settings::cfnLintIgnoreChecks).columns(30).comment(message("cloudformation.settings.cfnlint.ignoreChecks.comment")) + } + row(message("cloudformation.settings.cfnlint.includeChecks")) { + textField().bindText(settings::cfnLintIncludeChecks).columns(30).comment(message("cloudformation.settings.cfnlint.includeChecks.comment")) + } + row(message("cloudformation.settings.cfnlint.customRules")) { + textField().bindText(settings::cfnLintCustomRules).columns(30).comment(message("cloudformation.settings.cfnlint.customRules.comment")) + } + row(message("cloudformation.settings.cfnlint.appendRules")) { + textField().bindText(settings::cfnLintAppendRules).columns(30).comment(message("cloudformation.settings.cfnlint.appendRules.comment")) + } + row(message("cloudformation.settings.cfnlint.overrideSpec")) { + textField().bindText(settings::cfnLintOverrideSpec).columns(30).comment(message("cloudformation.settings.cfnlint.overrideSpec.comment")) + } + row(message("cloudformation.settings.cfnlint.registrySchemas")) { + textField().bindText(settings::cfnLintRegistrySchemas).columns(30).comment(message("cloudformation.settings.cfnlint.registrySchemas.comment")) + } + } + + collapsibleGroup(message("cloudformation.settings.cfnguard.group")) { + row { + checkBox(message("cloudformation.settings.cfnguard.enable")).bindSelected(settings::isCfnGuardEnabled) + } + row { + checkBox(message("cloudformation.settings.cfnguard.validateOnChange")).bindSelected(settings::cfnGuardValidateOnChange) + } + row(message("cloudformation.settings.cfnguard.enabledRulePacks")) { + val selected = settings.cfnGuardEnabledRulePacks.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val checkBoxList = CheckBoxList().apply { + CFN_GUARD_RULE_PACKS.forEach { addItem(it, it, it in selected) } + } + + cell( + JBScrollPane(checkBoxList).apply { + preferredSize = JBUI.size(300, 150) + } + ).onApply { + settings.cfnGuardEnabledRulePacks = CFN_GUARD_RULE_PACKS.filter { checkBoxList.isItemSelected(it) }.joinToString(",") + }.onReset { + val current = settings.cfnGuardEnabledRulePacks.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + CFN_GUARD_RULE_PACKS.forEachIndexed { _, pack -> + checkBoxList.setItemSelected(pack, pack in current) + } + }.onIsModified { + val current = settings.cfnGuardEnabledRulePacks.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + val ui = CFN_GUARD_RULE_PACKS.filter { checkBoxList.isItemSelected(it) }.toSet() + current != ui + } + } + row(message("cloudformation.settings.cfnguard.rulesFile")) { + textFieldWithBrowseButton( + fileChooserDescriptor = FileChooserDescriptorFactory.singleFile() + ).bindText(settings::cfnGuardRulesFile).columns(30).comment(message("cloudformation.settings.cfnguard.rulesFile.comment")) + } + } + } + + override fun apply() { + super.apply() + settings.notifySettingsChanged() + } + + override fun getId(): String = "aws.cloudformation" +} + +private val CFN_GUARD_RULE_PACKS = listOf( + "ABS-CCIGv2-Material", + "ABS-CCIGv2-Standard", + "acsc-essential-8", + "acsc-ism", + "apra-cpg-234", + "bnm-rmit", + "cis-aws-benchmark-level-1", + "cis-aws-benchmark-level-2", + "cis-critical-security-controls-v8-ig1", + "cis-critical-security-controls-v8-ig2", + "cis-critical-security-controls-v8-ig3", + "cis-top-20", + "cisa-ce", + "cmmc-level-1", + "cmmc-level-2", + "cmmc-level-3", + "cmmc-level-4", + "cmmc-level-5", + "enisa-cybersecurity-guide-for-smes", + "ens-high", + "ens-low", + "ens-medium", + "FDA-21CFR-Part-11", + "FedRAMP-Low", + "FedRAMP-Moderate", + "ffiec", + "hipaa-security", + "K-ISMS", + "mas-notice-655", + "mas-trmg", + "nbc-trmg", + "ncsc-cafv3", + "ncsc", + "nerc", + "nist-1800-25", + "nist-800-171", + "nist-800-172", + "nist-800-181", + "nist-csf", + "nist-privacy-framework", + "NIST800-53Rev4", + "NIST800-53Rev5", + "nzism", + "PCI-DSS-3-2-1", + "rbi-bcsf-ucb", + "rbi-md-itf", + "us-nydfs", + "wa-Reliability-Pillar", + "wa-Security-Pillar", +) diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt new file mode 100644 index 00000000000..3b019a08455 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPromptStateTest.kt @@ -0,0 +1,69 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.testFramework.ApplicationRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class CfnLspIntroPromptStateTest { + + @Rule + @JvmField + val applicationRule = ApplicationRule() + + private lateinit var promptState: CfnLspIntroPromptState + + @Before + fun setUp() { + promptState = CfnLspIntroPromptState.getInstance() + promptState.loadState(CfnLspIntroPromptStateData(hasResponded = false)) + } + + @Test + fun `default state has not responded`() { + val state = CfnLspIntroPromptStateData() + assertThat(state.hasResponded).isFalse() + } + + @Test + fun `hasResponded persists through state`() { + promptState.setResponded() + assertThat(promptState.getState().hasResponded).isTrue() + } + + @Test + fun `loadState restores values`() { + promptState.loadState(CfnLspIntroPromptStateData(hasResponded = true)) + assertThat(promptState.hasResponded()).isTrue() + } + + @Test + fun `explore choice marks permanent response`() { + promptState.setResponded() + assertThat(promptState.hasResponded()).isTrue() + } + + @Test + fun `dont show again choice marks permanent response`() { + promptState.setResponded() + assertThat(promptState.hasResponded()).isTrue() + } + + @Test + fun `should prompt when no prior interaction`() { + assertThat(promptState.hasResponded()).isFalse() + + val shouldPrompt = !promptState.hasResponded() + assertThat(shouldPrompt).isTrue() + } + + @Test + fun `should not prompt when permanently responded`() { + promptState.setResponded() + assertThat(promptState.hasResponded()).isTrue() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt new file mode 100644 index 00000000000..16a58b4222d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/documents/CfnDocumentManagerTest.kt @@ -0,0 +1,79 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.documents + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class CfnDocumentManagerTest { + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var documentManager: CfnDocumentManager + + @Before + fun setUp() { + documentManager = CfnDocumentManager.getInstance(projectRule.project) + } + + @Test + fun `getValidTemplates filters by cfnType template`() { + val documents = listOf( + createDocument("template.yaml", "template", "yaml"), + createDocument("config.json", "other", "json"), + createDocument("stack.yaml", "template", "yaml") + ) + + documentManager.updateDocuments(documents) + + val validTemplates = documentManager.getValidTemplates() + assertThat(validTemplates).hasSize(2) + assertThat(validTemplates.map { it.fileName }).containsExactly("template.yaml", "stack.yaml") + } + + @Test + fun `getValidTemplates returns empty list when no templates`() { + val documents = listOf( + createDocument("config.json", "other", "json"), + createDocument("readme.md", "other", "md") + ) + + documentManager.updateDocuments(documents) + + val validTemplates = documentManager.getValidTemplates() + assertThat(validTemplates).isEmpty() + } + + @Test + fun `updateDocuments replaces existing documents`() { + val initialDocs = listOf(createDocument("old.yaml", "template", "yaml")) + documentManager.updateDocuments(initialDocs) + assertThat(documentManager.getValidTemplates()).hasSize(1) + + val newDocs = listOf( + createDocument("new1.yaml", "template", "yaml"), + createDocument("new2.yaml", "template", "yaml") + ) + documentManager.updateDocuments(newDocs) + + val validTemplates = documentManager.getValidTemplates() + assertThat(validTemplates).hasSize(2) + assertThat(validTemplates.map { it.fileName }).containsExactly("new1.yaml", "new2.yaml") + } + + private fun createDocument(fileName: String, cfnType: String, languageId: String) = DocumentMetadata( + uri = "file:///path/to/$fileName", + fileName = fileName, + ext = fileName.substringAfterLast('.'), + type = "document", + cfnType = cfnType, + languageId = languageId, + version = 1, + lineCount = 10 + ) +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt new file mode 100644 index 00000000000..cc7cb24dbe7 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/ResourcesNodeTest.kt @@ -0,0 +1,100 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceLoader +import software.aws.toolkits.jetbrains.services.cfnlsp.resources.ResourceTypesManager + +class ResourcesNodeTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `shows add resource type node when no types selected`() { + val mockResourceTypesManager = mock() + val mockResourceLoader = mock() + + whenever(mockResourceTypesManager.getSelectedResourceTypes()).thenReturn(emptySet()) + + val node = ResourcesNode(projectRule.project, mockResourceTypesManager, mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(1) // Only AddResourceTypeNode + assertThat(children.filterIsInstance()).hasSize(1) + assertThat(children.filterIsInstance()).hasSize(0) + } + + @Test + fun `shows resource type nodes for selected types`() { + val mockResourceTypesManager = mock() + val mockResourceLoader = mock() + + whenever(mockResourceTypesManager.getSelectedResourceTypes()).thenReturn( + setOf("AWS::EC2::Instance", "AWS::S3::Bucket") + ) + + val node = ResourcesNode(projectRule.project, mockResourceTypesManager, mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(2) // Only 2 ResourceTypeNodes (no AddResourceTypeNode when types are selected) + assertThat(children.filterIsInstance()).hasSize(2) + assertThat(children.filterIsInstance()).hasSize(0) // No AddResourceTypeNode when types are selected + } + + @Test + fun `resource type node shows no resources when empty`() { + val mockResourceLoader = mock() + + whenever(mockResourceLoader.isLoaded("AWS::EC2::Instance")).thenReturn(true) + whenever(mockResourceLoader.getResourceIdentifiers("AWS::EC2::Instance")).thenReturn(emptyList()) + + val node = ResourceTypeNode(projectRule.project, "AWS::EC2::Instance", mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(1) + assertThat(children.first()).isInstanceOf(NoResourcesNode::class.java) + } + + @Test + fun `resource type node shows resource nodes when loaded`() { + val mockResourceLoader = mock() + + whenever(mockResourceLoader.isLoaded("AWS::EC2::Instance")).thenReturn(true) + whenever(mockResourceLoader.getResourceIdentifiers("AWS::EC2::Instance")).thenReturn( + listOf("testResource1", "testResource2") + ) + whenever(mockResourceLoader.hasMore("AWS::EC2::Instance")).thenReturn(false) + + val node = ResourceTypeNode(projectRule.project, "AWS::EC2::Instance", mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(2) + assertThat(children.filterIsInstance()).hasSize(2) + } + + @Test + fun `resource type node shows load more when has pagination`() { + val mockResourceLoader = mock() + + whenever(mockResourceLoader.isLoaded("AWS::EC2::Instance")).thenReturn(true) + whenever(mockResourceLoader.getResourceIdentifiers("AWS::EC2::Instance")).thenReturn( + listOf("testResource") + ) + whenever(mockResourceLoader.hasMore("AWS::EC2::Instance")).thenReturn(true) + + val node = ResourceTypeNode(projectRule.project, "AWS::EC2::Instance", mockResourceLoader) + val children = node.children + + assertThat(children).hasSize(2) // 1 ResourceNode + 1 LoadMoreResourcesNode + assertThat(children.filterIsInstance()).hasSize(1) + assertThat(children.filterIsInstance()).hasSize(1) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt new file mode 100644 index 00000000000..17df59bab2d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/explorer/nodes/StacksNodeTest.kt @@ -0,0 +1,169 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes + +import com.intellij.icons.AllIcons +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.ChangeSetsManager +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.StacksManager + +class StacksNodeTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockStacksManager: StacksManager + private lateinit var mockChangeSetsManager: ChangeSetsManager + private lateinit var stacksNode: StacksNode + + @Before + fun setUp() { + mockStacksManager = mock() + mockChangeSetsManager = mock() + stacksNode = StacksNode(projectRule.project, mockStacksManager, mockChangeSetsManager) + } + + @Test + fun `getChildren returns empty and triggers reload when not loaded`() { + whenever(mockStacksManager.isLoaded()).thenReturn(false) + + val children = stacksNode.children + + assertThat(children).isEmpty() + org.mockito.kotlin.verify(mockStacksManager).reload() + } + + @Test + fun `getChildren returns NoStacksNode when loaded but empty`() { + whenever(mockStacksManager.isLoaded()).thenReturn(true) + whenever(mockStacksManager.get()).thenReturn(emptyList()) + + val children = stacksNode.children + + assertThat(children).hasSize(1) + assertThat(children.first()).isInstanceOf(NoStacksNode::class.java) + } + + @Test + fun `getChildren returns stack nodes when loaded with stacks`() { + whenever(mockStacksManager.isLoaded()).thenReturn(true) + whenever(mockStacksManager.get()).thenReturn( + listOf( + StackSummary(stackName = "stack-1", stackStatus = "CREATE_COMPLETE"), + StackSummary(stackName = "stack-2", stackStatus = "UPDATE_COMPLETE") + ) + ) + whenever(mockStacksManager.hasMore()).thenReturn(false) + + val children = stacksNode.children + + assertThat(children).hasSize(2) + assertThat(children).allMatch { it is StackNode } + } + + @Test + fun `getChildren includes LoadMoreStacksNode when hasMore is true`() { + whenever(mockStacksManager.isLoaded()).thenReturn(true) + whenever(mockStacksManager.get()).thenReturn( + listOf( + StackSummary(stackName = "stack-1", stackStatus = "CREATE_COMPLETE") + ) + ) + whenever(mockStacksManager.hasMore()).thenReturn(true) + + val children = stacksNode.children + + assertThat(children).hasSize(2) + assertThat(children.last()).isInstanceOf(LoadMoreStacksNode::class.java) + } + + @Test + fun `isAlwaysShowPlus returns true`() { + assertThat(stacksNode.isAlwaysShowPlus).isTrue() + } +} + +class StackNodeTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockChangeSetsManager: ChangeSetsManager + + @Before + fun setUp() { + mockChangeSetsManager = mock() + } + + @Test + fun `getStackIcon returns success icon for CREATE_COMPLETE`() { + val icon = getIconForStatus("CREATE_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.InspectionsOK) + } + + @Test + fun `getStackIcon returns success icon for UPDATE_COMPLETE`() { + val icon = getIconForStatus("UPDATE_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.InspectionsOK) + } + + @Test + fun `getStackIcon returns error icon for CREATE_FAILED`() { + val icon = getIconForStatus("CREATE_FAILED") + assertThat(icon).isEqualTo(AllIcons.General.Error) + } + + @Test + fun `getStackIcon returns error icon for ROLLBACK_COMPLETE`() { + val icon = getIconForStatus("ROLLBACK_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.Error) + } + + @Test + fun `getStackIcon returns error icon for UPDATE_ROLLBACK_COMPLETE`() { + val icon = getIconForStatus("UPDATE_ROLLBACK_COMPLETE") + assertThat(icon).isEqualTo(AllIcons.General.Error) + } + + @Test + fun `getStackIcon returns progress icon for CREATE_IN_PROGRESS`() { + val icon = getIconForStatus("CREATE_IN_PROGRESS") + assertThat(icon).isEqualTo(AllIcons.Process.Step_1) + } + + @Test + fun `getStackIcon returns progress icon for UPDATE_IN_PROGRESS`() { + val icon = getIconForStatus("UPDATE_IN_PROGRESS") + assertThat(icon).isEqualTo(AllIcons.Process.Step_1) + } + + @Test + fun `getStackIcon returns folder icon for null status`() { + val icon = getIconForStatus(null) + assertThat(icon).isEqualTo(AllIcons.Nodes.Folder) + } + + @Test + fun `getStackIcon returns folder icon for unknown status`() { + val icon = getIconForStatus("UNKNOWN_STATUS") + assertThat(icon).isEqualTo(AllIcons.Nodes.Folder) + } + + private fun getIconForStatus(status: String?) = when { + status == null -> AllIcons.Nodes.Folder + status.contains("COMPLETE") && !status.contains("ROLLBACK") -> AllIcons.General.InspectionsOK + status.contains("FAILED") || status.contains("ROLLBACK") -> AllIcons.General.Error + status.contains("PROGRESS") -> AllIcons.Process.Step_1 + else -> AllIcons.Nodes.Folder + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt new file mode 100644 index 00000000000..1eea49dd3ca --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt @@ -0,0 +1,239 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.RefreshResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceSummary +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.SearchResourceResult +import java.util.concurrent.CompletableFuture + +class ResourceLoaderTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `initially has no cached resources`() { + val loader = ResourceLoader(projectRule.project) + + assertThat(loader.getCachedResources("AWS::EC2::Instance")).isNull() + assertThat(loader.isLoaded("AWS::EC2::Instance")).isFalse() + assertThat(loader.getResourceIdentifiers("AWS::EC2::Instance")).isEmpty() + assertThat(loader.hasMore("AWS::EC2::Instance")).isFalse() + assertThat(loader.getLoadedResourceTypes()).isEmpty() + } + + @Test + fun `refreshResources sends request to LSP server`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + val mockResult = RefreshResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1"), null) + ) + ) + whenever(mockClientService.refreshResources(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + loader.clientServiceProvider = { mockClientService } + loader.refreshResources("AWS::EC2::Instance") + + val paramsCaptor = argumentCaptor() + verify(mockClientService).refreshResources(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.resources).hasSize(1) + assertThat(paramsCaptor.firstValue.resources.first().resourceType).isEqualTo("AWS::EC2::Instance") + } + + @Test + fun `searchResource adds found resource to cache`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + val mockResourceSummary = ResourceSummary("AWS::EC2::Instance", listOf("test-instance")) + val mockResult = SearchResourceResult(found = true, resource = mockResourceSummary) + whenever(mockClientService.searchResource(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + loader.clientServiceProvider = { mockClientService } + + assertThat(loader.getCachedResources("AWS::EC2::Instance")).isNull() + + val result = loader.searchResource("AWS::EC2::Instance", "test-instance") + assertThat(result.get()).isTrue() + + val paramsCaptor = argumentCaptor() + verify(mockClientService).searchResource(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.resourceType).isEqualTo("AWS::EC2::Instance") + assertThat(paramsCaptor.firstValue.identifier).isEqualTo("test-instance") + + // Resource should now be in cache + val cachedResources = loader.getResourceIdentifiers("AWS::EC2::Instance") + assertThat(cachedResources).contains("test-instance") + assertThat(loader.isLoaded("AWS::EC2::Instance")).isTrue() + } + + @Test + fun `searchResource returns false when resource not found`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + val mockResult = SearchResourceResult(found = false, resource = null) + whenever(mockClientService.searchResource(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + loader.clientServiceProvider = { mockClientService } + + val result = loader.searchResource("AWS::EC2::Instance", "test-instance") + + assertThat(result.get()).isFalse() + + val paramsCaptor = argumentCaptor() + verify(mockClientService).searchResource(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.resourceType).isEqualTo("AWS::EC2::Instance") + assertThat(paramsCaptor.firstValue.identifier).isEqualTo("test-instance") + } + + @Test + fun `searchResource handles exception gracefully`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + + whenever(mockClientService.searchResource(any())).thenReturn(CompletableFuture.failedFuture(RuntimeException("Test exception"))) + loader.clientServiceProvider = { mockClientService } + + val result = loader.searchResource("AWS::EC2::Instance", "test-instance") + + assertThat(result.get()).isFalse() + } + + @Test + fun `addListener adds listener and notifies on clear`() { + val loader = ResourceLoader(projectRule.project) + var notificationReceived = false + var notifiedResourceType: String? = null + + val listener: ResourcesChangeListener = { resourceType, resources -> + notificationReceived = true + notifiedResourceType = resourceType + } + + loader.addListener(listener) + loader.clear("AWS::EC2::Instance") + + assertThat(notificationReceived).isTrue() + assertThat(notifiedResourceType).isEqualTo("AWS::EC2::Instance") + } + + @Test + fun `clear with null clears all resource types`() { + val loader = ResourceLoader(projectRule.project) + var ec2Cleared = false + var s3Cleared = false + + loader.addListener { resourceType, resources -> + when (resourceType) { + "AWS::EC2::Instance" -> ec2Cleared = resources.isEmpty() + "AWS::S3::Bucket" -> s3Cleared = resources.isEmpty() + } + } + + loader.clear("AWS::EC2::Instance") // Add some state + loader.clear("AWS::S3::Bucket") // Add some state + + loader.clear(null) + + assertThat(ec2Cleared).isTrue() + assertThat(s3Cleared).isTrue() + } + + @Test + fun `dispose clears listeners`() { + val loader = ResourceLoader(projectRule.project) + var notificationReceived = false + val listener: ResourcesChangeListener = { _, _ -> notificationReceived = true } + + loader.addListener(listener) + loader.dispose() + loader.clear("AWS::EC2::Instance") + + // Should not receive notification after dispose + assertThat(notificationReceived).isFalse() + } + + @Test + fun `loadMoreResources does nothing when no nextToken`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + loader.clientServiceProvider = { mockClientService } + + // First load some resources without nextToken + val mockResult = RefreshResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1"), null) + ) + ) + whenever(mockClientService.refreshResources(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + loader.refreshResources("AWS::EC2::Instance") + + // Reset mock to verify no additional calls + org.mockito.kotlin.reset(mockClientService) + + loader.loadMoreResources("AWS::EC2::Instance") + + // Should not make any LSP calls since no nextToken + verify(mockClientService, never()).listResources(any()) + } + + @Test + fun `loadMoreResources loads additional resources when nextToken exists`() { + val mockClientService = mock() + val loader = ResourceLoader(projectRule.project) + loader.clientServiceProvider = { mockClientService } + + // First load with nextToken + val initialResult = RefreshResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1"), "token123") + ) + ) + whenever(mockClientService.refreshResources(any())).thenReturn(CompletableFuture.completedFuture(initialResult)) + + loader.refreshResources("AWS::EC2::Instance") + + // Mock loadMore response - LSP server returns cumulative results + val loadMoreResult = ListResourcesResult( + resources = listOf( + ResourceSummary("AWS::EC2::Instance", listOf("test-instance-1", "test-instance-2"), null) + ) + ) + whenever(mockClientService.listResources(any())).thenReturn(CompletableFuture.completedFuture(loadMoreResult)) + + loader.loadMoreResources("AWS::EC2::Instance") + + // Verify listResources was called with nextToken + val paramsCaptor = argumentCaptor() + verify(mockClientService).listResources(paramsCaptor.capture()) + assertThat(paramsCaptor.firstValue.resources?.first()?.nextToken).isEqualTo("token123") + + // Verify resources use server's cumulative results + val allResources = loader.getResourceIdentifiers("AWS::EC2::Instance") + assertThat(allResources).containsExactly("test-instance-1", "test-instance-2") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt new file mode 100644 index 00000000000..fc74b1704f1 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceStateServiceTest.kt @@ -0,0 +1,116 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.ResourceNode +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStackManagementResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStatePurpose +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceStateResult +import java.util.concurrent.CompletableFuture + +class ResourceStateServiceTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `importResourceState calls LSP client with correct params`() { + val mockClientService = mock() + val stateService = ResourceStateService(projectRule.project) + stateService.clientServiceProvider = { mockClientService } + + val mockResult = ResourceStateResult( + successfulImports = mapOf("AWS::EC2::Instance" to listOf("test-ec2")), + failedImports = emptyMap(), + completionItem = null, + warning = null + ) + whenever(mockClientService.getResourceState(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + // Mock the editor to return valid values + val mockEditor = mock() + whenever(mockEditor.getActiveEditor()).thenReturn(mock()) + whenever(mockEditor.getActiveDocumentUri()).thenReturn("file:///test.yaml") + stateService.editor = mockEditor + + val resourceNode = mock() + whenever(resourceNode.resourceType).thenReturn("AWS::EC2::Instance") + whenever(resourceNode.resourceIdentifier).thenReturn("test-ec2") + + stateService.importResourceState(listOf(resourceNode)) + + val paramsCaptor = argumentCaptor() + verify(mockClientService).getResourceState(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.purpose).isEqualTo(ResourceStatePurpose.IMPORT.value) + assertThat(paramsCaptor.firstValue.resourceSelections).hasSize(1) + assertThat(paramsCaptor.firstValue.resourceSelections?.first()?.resourceType).isEqualTo("AWS::EC2::Instance") + assertThat(paramsCaptor.firstValue.resourceSelections?.first()?.resourceIdentifiers).containsExactly("test-ec2") + } + + @Test + fun `cloneResourceState calls LSP client with correct params`() { + val mockClientService = mock() + val stateService = ResourceStateService(projectRule.project) + stateService.clientServiceProvider = { mockClientService } + + val mockResult = ResourceStateResult( + successfulImports = mapOf("AWS::S3::Bucket" to listOf("test-bucket")), + failedImports = emptyMap(), + completionItem = null, + warning = null + ) + whenever(mockClientService.getResourceState(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + // Mock the editor to return valid values + val mockEditor = mock() + whenever(mockEditor.getActiveEditor()).thenReturn(mock()) + whenever(mockEditor.getActiveDocumentUri()).thenReturn("file:///test.yaml") + stateService.editor = mockEditor + + val resourceNode = mock() + whenever(resourceNode.resourceType).thenReturn("AWS::S3::Bucket") + whenever(resourceNode.resourceIdentifier).thenReturn("test-bucket") + + stateService.cloneResourceState(listOf(resourceNode)) + + val paramsCaptor = argumentCaptor() + verify(mockClientService).getResourceState(paramsCaptor.capture()) + + assertThat(paramsCaptor.firstValue.purpose).isEqualTo(ResourceStatePurpose.CLONE.value) + } + + @Test + fun `getStackManagementInfo calls LSP client`() { + val mockClientService = mock() + val stateService = ResourceStateService(projectRule.project) + stateService.clientServiceProvider = { mockClientService } + + val mockResult = ResourceStackManagementResult( + physicalResourceId = "test-ec2", + managedByStack = true, + stackName = "test-stack", + stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/12345" + ) + whenever(mockClientService.getStackManagementInfo(any())).thenReturn(CompletableFuture.completedFuture(mockResult)) + + val resourceNode = mock() + whenever(resourceNode.resourceIdentifier).thenReturn("test-ec2") + + stateService.getStackManagementInfo(resourceNode) + + verify(mockClientService).getStackManagementInfo("test-ec2") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt new file mode 100644 index 00000000000..5a0a36f9bfe --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceTypesManagerTest.kt @@ -0,0 +1,217 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.resources + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTypesResult +import java.util.concurrent.CompletableFuture + +class ResourceTypesManagerTest { + + @get:Rule + val projectRule = ProjectRule() + + @Test + fun `initially has no selected resource types`() { + val manager = ResourceTypesManager(projectRule.project) + + assertThat(manager.getSelectedResourceTypes()).isEmpty() + assertThat(manager.areTypesLoaded()).isFalse() + assertThat(manager.getAvailableResourceTypes()).isEmpty() + } + + @Test + fun `can add resource types`() { + val manager = ResourceTypesManager(projectRule.project) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + + manager.addResourceType("AWS::S3::Bucket") + assertThat(manager.getSelectedResourceTypes()).containsExactlyInAnyOrder( + "AWS::EC2::Instance", + "AWS::S3::Bucket" + ) + } + + @Test + fun `loads available types from LSP server`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + val mockResult = ResourceTypesResult(listOf("AWS::EC2::Instance", "AWS::S3::Bucket")) + whenever(mockClientService.listResourceTypes()).thenReturn(CompletableFuture.completedFuture(mockResult)) + + manager.clientServiceProvider = { mockClientService } + + manager.loadAvailableTypes() + + verify(mockClientService).listResourceTypes() + assertThat(manager.getAvailableResourceTypes()).containsExactlyInAnyOrder("AWS::EC2::Instance", "AWS::S3::Bucket") + assertThat(manager.areTypesLoaded()).isTrue() + } + + @Test + fun `removeResourceType sends request to LSP server`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.completedFuture(null)) + + manager.clientServiceProvider = { mockClientService } + + manager.addResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + + manager.removeResourceType("AWS::EC2::Instance") + + verify(mockClientService).removeResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).isEmpty() + } + + @Test + fun `adding duplicate resource type is ignored`() { + val manager = ResourceTypesManager(projectRule.project) + + manager.addResourceType("AWS::EC2::Instance") + manager.addResourceType("AWS::EC2::Instance") + + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + } + + @Test + fun `removeResourceType removes from state immediately even when LSP server fails`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.failedFuture(RuntimeException("Test exception"))) + + manager.clientServiceProvider = { mockClientService } + + manager.addResourceType("AWS::EC2::Instance") + assertThat(manager.getSelectedResourceTypes()).containsExactly("AWS::EC2::Instance") + + manager.removeResourceType("AWS::EC2::Instance") + + verify(mockClientService).removeResourceType("AWS::EC2::Instance") + // Should remove from state immediately for responsive UI, even if LSP call fails + assertThat(manager.getSelectedResourceTypes()).isEmpty() + } + + @Test + fun `removeResourceType does nothing for non-existent type`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + manager.clientServiceProvider = { mockClientService } + + manager.removeResourceType("AWS::EC2::Instance") + + verify(mockClientService, never()).removeResourceType(any()) + assertThat(manager.getSelectedResourceTypes()).isEmpty() + } + + @Test + fun `loadAvailableTypes handles null result gracefully`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.listResourceTypes()).thenReturn(CompletableFuture.completedFuture(null)) + + manager.clientServiceProvider = { mockClientService } + + manager.loadAvailableTypes() + + verify(mockClientService).listResourceTypes() + assertThat(manager.getAvailableResourceTypes()).isEmpty() + assertThat(manager.areTypesLoaded()).isFalse() + } + + @Test + fun `listeners are notified when resource types change`() { + val manager = ResourceTypesManager(projectRule.project) + var notificationCount = 0 + val listener: ResourceTypesChangeListener = { notificationCount++ } + + manager.addListener(listener) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(1) + + manager.addResourceType("AWS::S3::Bucket") + assertThat(notificationCount).isEqualTo(2) + + // Adding duplicate should not notify + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `listeners are notified when resource types are removed successfully`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.completedFuture(null)) + + manager.clientServiceProvider = { mockClientService } + + var notificationCount = 0 + val listener: ResourceTypesChangeListener = { notificationCount++ } + manager.addListener(listener) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(1) + + manager.removeResourceType("AWS::EC2::Instance") + + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `listeners are notified immediately when resource type removal is requested`() { + val mockClientService = mock() + val manager = ResourceTypesManager(projectRule.project) + + whenever(mockClientService.removeResourceType(any())).thenReturn(CompletableFuture.failedFuture(RuntimeException("Test exception"))) + + manager.clientServiceProvider = { mockClientService } + + var notificationCount = 0 + val listener: ResourceTypesChangeListener = { notificationCount++ } + manager.addListener(listener) + + manager.addResourceType("AWS::EC2::Instance") + assertThat(notificationCount).isEqualTo(1) + + manager.removeResourceType("AWS::EC2::Instance") + + // Should notify immediately when removal is requested (responsive UI) + assertThat(notificationCount).isEqualTo(2) + } + + @Test + fun `state persistence works correctly`() { + val manager = ResourceTypesManager(projectRule.project) + + manager.addResourceType("AWS::EC2::Instance") + manager.addResourceType("AWS::S3::Bucket") + + val state = manager.state + assertThat(state.selectedTypes).containsExactlyInAnyOrder("AWS::EC2::Instance", "AWS::S3::Bucket") + + // Simulate loading state + val newManager = ResourceTypesManager(projectRule.project) + newManager.loadState(state) + + assertThat(newManager.getSelectedResourceTypes()).containsExactlyInAnyOrder("AWS::EC2::Instance", "AWS::S3::Bucket") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt new file mode 100644 index 00000000000..e7a86c7b884 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/CfnOperationStatusServiceTest.kt @@ -0,0 +1,130 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase + +class CfnOperationStatusServiceTest { + + @Rule + @JvmField + val projectRule = ProjectRule() + + private val service by lazy { CfnOperationStatusService(projectRule.project) } + + @Test + fun `status text is empty when no operations`() { + assertThat(service.getStatusText()).isEmpty() + } + + @Test + fun `single active validation shows verb and stack name`() { + service.acquire("my-stack", OperationType.VALIDATION) + assertThat(service.getStatusText()).isEqualTo("Validating my-stack") + } + + @Test + fun `single active deployment shows verb and stack name`() { + service.acquire("my-stack", OperationType.DEPLOYMENT, "cs-1") + assertThat(service.getStatusText()).isEqualTo("Deploying my-stack") + } + + @Test + fun `single completed validation shows done label`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.update(StackActionPhase.VALIDATION_COMPLETE) + assertThat(service.getStatusText()).isEqualTo("Validated my-stack") + } + + @Test + fun `single failed validation shows failed label`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.update(StackActionPhase.VALIDATION_FAILED) + assertThat(service.getStatusText()).isEqualTo("Validation Failed: my-stack") + } + + @Test + fun `single completed deployment shows done label`() { + val handle = service.acquire("my-stack", OperationType.DEPLOYMENT, "cs-1") + handle.update(StackActionPhase.DEPLOYMENT_COMPLETE) + assertThat(service.getStatusText()).isEqualTo("Deployed my-stack") + } + + @Test + fun `single failed deployment shows failed label`() { + val handle = service.acquire("my-stack", OperationType.DEPLOYMENT, "cs-1") + handle.update(StackActionPhase.DEPLOYMENT_FAILED) + assertThat(service.getStatusText()).isEqualTo("Deployment Failed: my-stack") + } + + @Test + fun `multiple operations shows count`() { + service.acquire("stack-1", OperationType.VALIDATION) + service.acquire("stack-2", OperationType.DEPLOYMENT) + assertThat(service.getStatusText()).isEqualTo("AWS CloudFormation (2)") + } + + @Test + fun `released operation excluded from status text`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.release() + assertThat(service.getStatusText()).isEmpty() + } + + @Test + fun `released operation excluded from active operations`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.release() + assertThat(service.getActiveOperations()).isEmpty() + } + + @Test + fun `released operation still in all operations`() { + val handle = service.acquire("my-stack", OperationType.VALIDATION) + handle.release() + assertThat(service.getAllOperations()).hasSize(1) + } + + @Test + fun `ref counting tracks multiple acquires and releases`() { + val h1 = service.acquire("stack-1", OperationType.VALIDATION) + val h2 = service.acquire("stack-2", OperationType.DEPLOYMENT) + assertThat(service.getActiveOperations()).hasSize(2) + + h1.release() + assertThat(service.getActiveOperations()).hasSize(1) + assertThat(service.getStatusText()).isEqualTo("Deploying stack-2") + + h2.release() + assertThat(service.getActiveOperations()).isEmpty() + } + + @Test + fun `update changes phase of correct operation`() { + val h1 = service.acquire("stack-1", OperationType.VALIDATION) + val h2 = service.acquire("stack-2", OperationType.DEPLOYMENT) + + h1.update(StackActionPhase.VALIDATION_COMPLETE) + + val ops = service.getActiveOperations() + val stack1 = ops.first { it.stackName == "stack-1" } + val stack2 = ops.first { it.stackName == "stack-2" } + assertThat(stack1.phase).isEqualTo(StackActionPhase.VALIDATION_COMPLETE) + assertThat(stack2.phase).isEqualTo(StackActionPhase.DEPLOYMENT_IN_PROGRESS) + } + + @Test + fun `concurrent acquires produce unique operations`() { + val handles = (1..10).map { service.acquire("stack-$it", OperationType.VALIDATION) } + assertThat(service.getActiveOperations()).hasSize(10) + assertThat(service.getStatusText()).isEqualTo("AWS CloudFormation (10)") + + handles.forEach { it.release() } + assertThat(service.getActiveOperations()).isEmpty() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt new file mode 100644 index 00000000000..7eadd237c33 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetDeletionWorkflowTest.kt @@ -0,0 +1,84 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeletionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import java.util.concurrent.CompletableFuture + +class ChangeSetDeletionWorkflowTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var workflow: ChangeSetDeletionWorkflow + + @Before + fun setUp() { + mockClientService = mock() + workflow = ChangeSetDeletionWorkflow(projectRule.project, mockClientService) + } + + @Test + fun `returns Failed when deleteChangeSet returns null`() { + whenever(mockClientService.deleteChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + + val result = workflow.delete("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + } + + @Test + fun `returns Success when deletion completes successfully`() { + whenever(mockClientService.deleteChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getChangeSetDeletionStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DELETION_COMPLETE, StackActionState.SUCCESSFUL) + ) + ) + + val result = workflow.delete("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Success::class.java) + } + + @Test + fun `returns Failed when deletion fails`() { + whenever(mockClientService.deleteChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getChangeSetDeletionStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DELETION_FAILED, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeChangeSetDeletionStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeDeletionStatusResult("id-1", StackActionPhase.DELETION_FAILED, StackActionState.FAILED, failureReason = "Access denied") + ) + ) + + val result = workflow.delete("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt new file mode 100644 index 00000000000..11ee0a40318 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ChangeSetsManagerTest.kt @@ -0,0 +1,64 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListChangeSetsResult +import java.util.concurrent.CompletableFuture + +class ChangeSetsManagerTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var changeSetsManager: ChangeSetsManager + + @Before + fun setUp() { + mockClientService = mock() + changeSetsManager = ChangeSetsManager(projectRule.project).apply { + clientServiceProvider = { mockClientService } + } + } + + @Test + fun `get returns empty list for unknown stack`() { + assertThat(changeSetsManager.get("unknown-stack")).isEmpty() + } + + @Test + fun `hasMore returns false for unknown stack`() { + assertThat(changeSetsManager.hasMore("unknown-stack")).isFalse() + } + + @Test + fun `fetchChangeSets calls listChangeSets on client service`() { + whenever(mockClientService.listChangeSets(any())).thenReturn( + CompletableFuture.completedFuture(ListChangeSetsResult(emptyList(), null)) + ) + + changeSetsManager.fetchChangeSets("my-stack") + + verify(mockClientService).listChangeSets(any()) + } + + @Test + fun `loadMoreChangeSets does nothing when no cached data`() { + changeSetsManager.loadMoreChangeSets("unknown-stack") + + verify(mockClientService, never()).listChangeSets(any()) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt new file mode 100644 index 00000000000..65fe90c1cc0 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/DeploymentWorkflowTest.kt @@ -0,0 +1,99 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeDeploymentStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewPanelTabber +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewTab +import software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views.StackViewWindowManager +import java.util.concurrent.CompletableFuture + +class DeploymentWorkflowTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var mockWindowManager: StackViewWindowManager + private lateinit var mockTabber: StackViewPanelTabber + private lateinit var workflow: DeploymentWorkflow + + @Before + fun setUp() { + mockClientService = mock() + mockWindowManager = mock() + mockTabber = mock() + workflow = DeploymentWorkflow(projectRule.project, mockClientService, mockWindowManager) + } + + @Test + fun `returns Failed when createDeployment returns null`() { + whenever(mockClientService.createDeployment(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + whenever(mockWindowManager.getOrOpenTabber("test-stack")).thenReturn(mockTabber) + + val result = workflow.deploy("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + verify(mockTabber).switchToTab(StackViewTab.EVENTS) + verify(mockTabber, never()).restartStatusPolling() + } + + @Test + fun `returns Success when deployment completes successfully`() { + whenever(mockClientService.createDeployment(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getDeploymentStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DEPLOYMENT_COMPLETE, StackActionState.SUCCESSFUL) + ) + ) + whenever(mockWindowManager.getOrOpenTabber("test-stack")).thenReturn(mockTabber) + + val result = workflow.deploy("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Success::class.java) + verify(mockTabber).switchToTab(StackViewTab.EVENTS) + verify(mockTabber).restartStatusPolling() + } + + @Test + fun `returns Failed when deployment fails`() { + whenever(mockClientService.createDeployment(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getDeploymentStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.DEPLOYMENT_FAILED, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeDeploymentStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeDeploymentStatusResult("id-1", StackActionPhase.DEPLOYMENT_FAILED, StackActionState.FAILED, failureReason = "Rollback") + ) + ) + + val result = workflow.deploy("test-stack", "changeset-1").get() + + assertThat(result).isInstanceOf(PollResult.Failed::class.java) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt new file mode 100644 index 00000000000..cebb95b0b22 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/StacksManagerTest.kt @@ -0,0 +1,98 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStacksResult +import java.util.concurrent.CompletableFuture + +class StacksManagerTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var stacksManager: StacksManager + + @Before + fun setUp() { + mockClientService = mock() + stacksManager = StacksManager(projectRule.project).apply { + clientServiceProvider = { mockClientService } + } + } + + @Test + fun `initial state is not loaded with empty stacks`() { + assertThat(stacksManager.isLoaded()).isFalse() + assertThat(stacksManager.get()).isEmpty() + assertThat(stacksManager.hasMore()).isFalse() + } + + @Test + fun `clear resets state and notifies listeners`() { + var notified = false + stacksManager.addListener { notified = true } + + stacksManager.clear() + + assertThat(stacksManager.isLoaded()).isFalse() + assertThat(stacksManager.get()).isEmpty() + assertThat(stacksManager.hasMore()).isFalse() + assertThat(notified).isTrue() + } + + @Test + fun `reload calls listStacks on client service`() { + whenever(mockClientService.listStacks(any())).thenReturn( + CompletableFuture.completedFuture(ListStacksResult(emptyList(), null)) + ) + + stacksManager.reload() + + verify(mockClientService).listStacks(any()) + } + + @Test + fun `loadMoreStacks does nothing when no nextToken`() { + stacksManager.loadMoreStacks() + + verify(mockClientService, never()).listStacks(any()) + } + + @Test + fun `listener is notified on clear`() { + val notifications = mutableListOf() + stacksManager.addListener { notifications.add(it.size) } + + stacksManager.clear() + + assertThat(notifications).containsExactly(0) + } + + @Test + fun `multiple listeners are all notified`() { + var listener1Called = false + var listener2Called = false + + stacksManager.addListener { listener1Called = true } + stacksManager.addListener { listener2Called = true } + + stacksManager.clear() + + assertThat(listener1Called).isTrue() + assertThat(listener2Called).isTrue() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt new file mode 100644 index 00000000000..6dea6f77f6f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/ValidationWorkflowTest.kt @@ -0,0 +1,126 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks + +import com.intellij.testFramework.ProjectRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateStackActionResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.CreateValidationParams +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeValidationStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackActionStatusResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionPhase +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackActionState +import java.util.concurrent.CompletableFuture + +class ValidationWorkflowTest { + + @JvmField + @Rule + val projectRule = ProjectRule() + + private lateinit var mockClientService: CfnClientService + private lateinit var workflow: ValidationWorkflow + + @Before + fun setUp() { + mockClientService = mock() + workflow = ValidationWorkflow(projectRule.project, mockClientService) + } + + @Test + fun `returns Failed when createValidation returns null`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Failed::class.java) + assertThat((result as ValidationResult.Failed).reason).isEqualTo("Failed to start validation") + } + + @Test + fun `returns Success when validation completes successfully`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.SUCCESSFUL, emptyList()) + ) + ) + whenever(mockClientService.describeValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeValidationStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.SUCCESSFUL) + ) + ) + whenever(mockClientService.describeChangeSet(any())).thenReturn( + CompletableFuture.completedFuture(null) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Success::class.java) + val success = result as ValidationResult.Success + assertThat(success.changeSetName).isEqualTo("changeset-1") + } + + @Test + fun `returns Failed when validation completes with failure state`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeValidationStatusResult("id-1", StackActionPhase.VALIDATION_COMPLETE, StackActionState.FAILED, failureReason = "Template error") + ) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Failed::class.java) + assertThat((result as ValidationResult.Failed).reason).isEqualTo("Template error") + } + + @Test + fun `returns Failed when phase is VALIDATION_FAILED`() { + whenever(mockClientService.createValidation(any())).thenReturn( + CompletableFuture.completedFuture(CreateStackActionResult("id-1", "changeset-1", "test-stack")) + ) + whenever(mockClientService.getValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + GetStackActionStatusResult("id-1", StackActionPhase.VALIDATION_FAILED, StackActionState.FAILED) + ) + ) + whenever(mockClientService.describeValidationStatus(any())).thenReturn( + CompletableFuture.completedFuture( + DescribeValidationStatusResult("id-1", StackActionPhase.VALIDATION_FAILED, StackActionState.FAILED, failureReason = "Syntax error") + ) + ) + + val result = workflow.validate(createParams()).get() + + assertThat(result).isInstanceOf(ValidationResult.Failed::class.java) + assertThat((result as ValidationResult.Failed).reason).isEqualTo("Syntax error") + } + + private fun createParams() = CreateValidationParams( + id = "test-id", + uri = "file:///test.yaml", + stackName = "test-stack", + keepChangeSet = true + ) +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt new file mode 100644 index 00000000000..19654718d10 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackEventsPanelTest.kt @@ -0,0 +1,408 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import com.intellij.ui.components.JBLabel +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.GetStackEventsResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackEvent +import software.aws.toolkits.jetbrains.utils.notifyInfo +import java.util.concurrent.CompletableFuture +import javax.swing.JScrollPane +import javax.swing.JTable + +class StackEventsPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + mockkStatic("software.aws.toolkits.jetbrains.utils.NotificationUtilsKt") + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addPollingListener(any(), any()) } returns mockk(relaxed = true) + every { mockCfnClient.clearStackEvents(any()) } returns CompletableFuture.completedFuture(null) + every { notifyInfo(any(), any(), any()) } returns Unit + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + unmockkStatic("software.aws.toolkits.jetbrains.utils.NotificationUtilsKt") + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + + @Test + fun `loads events on initialization`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + verify { mockCfnClient.getStackEvents(any()) } + } finally { + panel.dispose() + } + } + + @Test + fun `displays events in table correctly`() { + val events = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event1", + logicalResourceId = "MyResource", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:00Z", + operationId = "op1" + ), + StackEvent( + stackId = testStackArn, + eventId = "event2", + logicalResourceId = "MyFunction", + resourceType = "AWS::Lambda::Function", + resourceStatus = "CREATE_IN_PROGRESS", + timestamp = "2025-01-01T12:01:00Z", + operationId = "op2" + ) + ) + val result = GetStackEventsResult(events, null, false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.component).isNotNull() + } finally { + panel.dispose() + } + } + + @Test + fun `pagination buttons work correctly`() { + val events = (1..100).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, "nextToken123", false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + // Verify button states and text + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Next") + assertThat(panel.prevButton.isEnabled).isFalse() // Should be disabled on first page + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 2") // 100 events = 2 pages + } finally { + panel.dispose() + } + } + + @Test + fun `shows load more button when more data needed from server`() { + val events = (1..50).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, "nextToken123", false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Load More") + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 1") + } finally { + panel.dispose() + } + } + + @Test + fun `buttons disabled during loading`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + assertThat(panel.nextButton.isEnabled).isFalse() + assertThat(panel.prevButton.isEnabled).isFalse() + } finally { + panel.dispose() + } + } + + @Test + fun `page navigation updates button states correctly`() { + val events = (1..100).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, null, false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + // Initial state: page 1 of 2 + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 2") + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Next") + + // Navigate to page 2 + panel.nextButton.doClick() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Page 2 state: last page + assertThat(panel.pageLabel.text).isEqualTo("Page 2 of 2") + assertThat(panel.prevButton.isEnabled).isTrue() + assertThat(panel.nextButton.isEnabled).isFalse() + } finally { + panel.dispose() + } + } + + @Test + fun `onStackPolled triggers refresh`() { + val initialEvents = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event1", + logicalResourceId = "Resource1", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:00Z", + operationId = "op1" + ) + ) + val refreshEvents = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event2", + logicalResourceId = "Resource2", + resourceType = "AWS::Lambda::Function", + resourceStatus = "CREATE_IN_PROGRESS", + timestamp = "2025-01-01T12:01:00Z", + operationId = "op2" + ) + ) + + val initialResult = CompletableFuture.completedFuture(GetStackEventsResult(initialEvents, null, false)) + val refreshResult = CompletableFuture.completedFuture(GetStackEventsResult(refreshEvents, null, false)) + + every { mockCfnClient.getStackEvents(match { it.refresh != true }) } returns initialResult + every { mockCfnClient.getStackEvents(match { it.refresh == true }) } returns refreshResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + panel.onStackPolled() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + verify { mockCfnClient.getStackEvents(match { it.refresh == true }) } + } finally { + panel.dispose() + } + } + + @Test + fun `handles gap detection with full reload`() { + val initialEvents = listOf( + StackEvent( + stackId = testStackArn, + eventId = "event1", + logicalResourceId = "Resource1", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:00Z", + operationId = "op1" + ) + ) + val gapDetectedResult = GetStackEventsResult(emptyList(), null, true) + val reloadResult = GetStackEventsResult(initialEvents, null, false) + + val initialResult = CompletableFuture.completedFuture(GetStackEventsResult(initialEvents, null, false)) + val refreshResult = CompletableFuture.completedFuture(gapDetectedResult) + val fullReloadResult = CompletableFuture.completedFuture(reloadResult) + + every { mockCfnClient.getStackEvents(match { it.refresh != true && it.nextToken == null }) } returnsMany listOf(initialResult, fullReloadResult) + every { mockCfnClient.getStackEvents(match { it.refresh == true }) } returns refreshResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + panel.onStackPolled() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + verify(exactly = 3) { mockCfnClient.getStackEvents(any()) } + // Should show notification to user about gap detection + verify { notifyInfo("CloudFormation Events", "Event history reloaded due to high activity", projectRule.project) } + } finally { + panel.dispose() + } + } + + @Test + fun `clears events cache on dispose`() { + val futureResult = CompletableFuture.completedFuture(GetStackEventsResult(emptyList(), null, false)) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + panel.dispose() + + verify { mockCfnClient.clearStackEvents(any()) } + } + + @Test + fun `updates event count label correctly`() { + val events = (1..75).map { + StackEvent( + stackId = testStackArn, + eventId = "event$it", + logicalResourceId = "Resource$it", + resourceType = "AWS::S3::Bucket", + resourceStatus = "CREATE_COMPLETE", + timestamp = "2025-01-01T12:00:${it.toString().padStart(2, '0')}Z", + operationId = "op$it" + ) + } + val result = GetStackEventsResult(events, "nextToken123", false) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + val eventCountLabelField = panel.javaClass.getDeclaredField("eventCountLabel").apply { isAccessible = true } + val eventCountLabel = eventCountLabelField.get(panel) as JBLabel + + assertThat(eventCountLabel.text).isEqualTo("75 events loaded") + } finally { + panel.dispose() + } + } + + @Test + fun `handles API error correctly`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackEvents(any()) } returns futureResult + + val panel = StackEventsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + futureResult.completeExceptionally(RuntimeException("API error")) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.nextButton.isEnabled).isFalse() + assertThat(panel.pageLabel.text).isEqualTo("Page 1 of 1") + // Verify error message is displayed in table + val tableComponent = panel.component.components + .filterIsInstance() + .first().viewport.view as JTable + assertThat(tableComponent.getValueAt(0, 4)).asString().contains("Failed to load events:") + assertThat(tableComponent.getValueAt(0, 4)).asString().contains("API error") + } finally { + panel.dispose() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt new file mode 100644 index 00000000000..d958826453f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOutputsPanelTest.kt @@ -0,0 +1,257 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackOutput +import java.util.concurrent.CompletableFuture + +class StackOutputsPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addStatusListener(any(), any()) } returns mockk() + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + } + + @Test + fun `onStackUpdated triggers outputs reload and updates table correctly`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "my-test-stack") + + // Assert initial state + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.outputTable.rowCount).isEqualTo(1) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + + val testOutputs = listOf( + StackOutput( + outputKey = "WebsiteURL", + outputValue = "https://example.com", + description = "Website URL", + exportName = "MyStack-WebsiteURL" + ), + StackOutput( + outputKey = "DatabaseEndpoint", + outputValue = "db.example.com:5432", + description = "Database connection endpoint", + exportName = null + ) + ) + + val testStack = StackDetail( + stackName = "my-test-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = testOutputs + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("2 outputs") + assertThat(panel.consoleLink.isVisible).isTrue() + assertThat(panel.outputTable.rowCount).isEqualTo(2) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("WebsiteURL") + assertThat(panel.outputTable.getValueAt(0, 1)).isEqualTo("https://example.com") + assertThat(panel.outputTable.getValueAt(0, 2)).isEqualTo("Website URL") + assertThat(panel.outputTable.getValueAt(0, 3)).isEqualTo("MyStack-WebsiteURL") + assertThat(panel.outputTable.getValueAt(1, 3)).isEqualTo("") + } + + @Test + fun `onStackUpdated with empty outputs shows no outputs found`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "empty-stack") + + val testStack = StackDetail( + stackName = "empty-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = emptyList() + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isTrue() + assertThat(panel.outputTable.rowCount).isEqualTo(1) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + assertThat(panel.outputTable.getValueAt(0, 1)).isEqualTo("") + } + + @Test + fun `onStackUpdated with single output uses singular form`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "single-output-stack") + + val testStack = StackDetail( + stackName = "single-output-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = listOf( + StackOutput( + outputKey = "SingleOutput", + outputValue = "single-value", + description = "Single output description", + exportName = null + ) + ) + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("1 output") + } + + @Test + fun `onStackUpdated with empty stack ID hides console link`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + val testStack = StackDetail( + stackName = "test-stack", + stackId = "", + stackStatus = "CREATE_COMPLETE", + outputs = listOf( + StackOutput( + outputKey = "TestOutput", + outputValue = "test-value", + description = null, + exportName = null + ) + ) + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.consoleLink.isVisible).isFalse() + } + + @Test + fun `onStackUpdated with error shows error message`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "error-stack") + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + + // Complete with error + futureResult.completeExceptionally(RuntimeException("Test error")) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.outputTable.getValueAt(0, 0)).asString().contains("Failed to load outputs for stack error-stack") + assertThat(panel.outputTable.getValueAt(0, 0)).asString().contains("Test error") + } + + @Test + fun `onStackUpdated with null result shows empty state`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "null-stack") + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + + futureResult.complete(null) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + } + + @Test + fun `onStackUpdated with null outputs shows empty state`() { + val panel = StackOutputsPanel(projectRule.project, mockCoordinator, testStackArn, "null-outputs-stack") + + val testStack = StackDetail( + stackName = "null-outputs-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + outputs = null + ) + + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + panel.onStackStatusUpdated() + futureResult.complete(DescribeStackResult(testStack)) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.outputCountLabel.text).isEqualTo("0 outputs") + assertThat(panel.consoleLink.isVisible).isTrue() // as long as an error isn't thrown we should show console link + assertThat(panel.outputTable.rowCount).isEqualTo(1) + assertThat(panel.outputTable.getValueAt(0, 0)).isEqualTo("No outputs found") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt new file mode 100644 index 00000000000..6fba17e432e --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt @@ -0,0 +1,159 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail +import java.util.concurrent.CompletableFuture + +class StackOverviewPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addStatusListener(any(), any()) } returns mockk() + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + } + + @Test + fun `renderStack updates all field values correctly`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "my-test-stack") + + val testStack = StackDetail( + stackName = "my-test-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = "Test stack description", + creationTime = "2024-01-15T10:30:45Z", + lastUpdatedTime = "2024-01-15T11:00:00Z", + stackStatusReason = "Stack creation completed successfully" + ) + + panel.renderStack(testStack) + + assertThat(panel.stackNameValue.text).isEqualTo("my-test-stack") + assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE") + assertThat(panel.stackIdValue.text).isEqualTo(testStackArn) + assertThat(panel.descriptionValue.text).isEqualTo("Test stack description") + assertThat(panel.consoleLink.isVisible).isTrue() + } + + @Test + fun `renderStack with empty stack ID hides console link`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + val testStack = StackDetail( + stackName = "test-stack", + stackId = "", + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = null, + lastUpdatedTime = null, + stackStatusReason = null + ) + + panel.renderStack(testStack) + + assertThat(panel.consoleLink.isVisible).isFalse() + } + + @Test + fun `renderStack with null optional fields handles gracefully`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "minimal-stack") + + val testStack = StackDetail( + stackName = "minimal-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = null, + lastUpdatedTime = null, + stackStatusReason = null + ) + + panel.renderStack(testStack) + + assertThat(panel.stackNameValue.text).isEqualTo("minimal-stack") + assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE") + } + + @Test + fun `renderStack formats dates correctly`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "date-test-stack") + + val testStack = StackDetail( + stackName = "date-test-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = "2024-01-15T10:30:45Z", + lastUpdatedTime = "2024-01-15T11:00:00Z", + stackStatusReason = null + ) + + panel.renderStack(testStack) + + assertThat(panel.createdValue.text).contains("15/1/2024") + assertThat(panel.lastUpdatedValue.text).contains("15/1/2024") + } + + @Test + fun `onStackStatusUpdated triggers stack reload`() { + val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "my-stack") + + // Create a future we can control + val futureResult = CompletableFuture() + every { mockCfnClient.describeStack(any()) } returns futureResult + + // Should trigger reload + panel.onStackStatusUpdated() + + // Complete the future synchronously + val mockStack = StackDetail( + stackName = "my-stack", + stackId = testStackArn, + stackStatus = "CREATE_COMPLETE", + description = null, + creationTime = null, + lastUpdatedTime = null, + stackStatusReason = null + ) + futureResult.complete(DescribeStackResult(mockStack)) + + // Process EDT events to execute the invokeLater block from loadStackDetails + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + assertThat(panel.stackNameValue.text).isEqualTo("my-stack") + assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt new file mode 100644 index 00000000000..23b055749ef --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt @@ -0,0 +1,141 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel + +class StackPanelLayoutBuilderTest { + + @Test + fun `createTitleLabel creates label with correct styling`() { + val label = StackPanelLayoutBuilder.createTitleLabel("Test Label") + + assertThat(label.text).isEqualTo("Test Label") + assertThat(label.foreground).isEqualTo(UIUtil.getContextHelpForeground()) + assertThat(label.font.isBold).isTrue() + } + + @Test + fun `createFormPanel creates panel with GridBagLayout and default padding`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + + assertThat(panel.layout).isInstanceOf(GridBagLayout::class.java) + assertThat(panel.border).isNotNull() + } + + @Test + fun `createFormPanel creates panel with custom padding`() { + val panel = StackPanelLayoutBuilder.createFormPanel(30) + + assertThat(panel.layout).isInstanceOf(GridBagLayout::class.java) + assertThat(panel.border).isNotNull() + } + + @Test + fun `addLabeledField adds label and component to panel`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.NORTHWEST + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + } + val testComponent = JBLabel("Test Component") + + val nextRow = StackPanelLayoutBuilder.addLabeledField( + panel, + gbc, + 0, + "Test Field", + testComponent + ) + + assertThat(nextRow).isEqualTo(2) + assertThat(panel.componentCount).isEqualTo(2) // Label + component + + // Verify first component is the title label + val titleLabel = panel.getComponent(0) as JBLabel + assertThat(titleLabel.text).isEqualTo("Test Field") + assertThat(titleLabel.font.isBold).isTrue() + + // Verify second component is our test component + assertThat(panel.getComponent(1)).isEqualTo(testComponent) + } + + @Test + fun `addLabeledField with fillNone modifies constraints correctly`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints().apply { + anchor = GridBagConstraints.NORTHWEST + fill = GridBagConstraints.HORIZONTAL + weightx = 1.0 + } + val testComponent = JBLabel("Test Component") + + StackPanelLayoutBuilder.addLabeledField( + panel, + gbc, + 0, + "Test Field", + testComponent, + fillNone = true + ) + + // Constraints should be reset after the method + assertThat(gbc.fill).isEqualTo(GridBagConstraints.HORIZONTAL) + assertThat(gbc.anchor).isEqualTo(GridBagConstraints.NORTHWEST) + } + + @Test + fun `addLabeledField with isLast uses different insets`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints() + val testComponent = JBLabel("Test Component") + + val nextRow = StackPanelLayoutBuilder.addLabeledField( + panel, + gbc, + 0, + "Test Field", + testComponent, + isLast = true + ) + + assertThat(nextRow).isEqualTo(2) + assertThat(panel.componentCount).isEqualTo(2) + } + + @Test + fun `addFiller adds empty panel with correct constraints`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints() + + StackPanelLayoutBuilder.addFiller(panel, gbc, 2) + + assertThat(panel.componentCount).isEqualTo(1) + assertThat(panel.getComponent(0)).isInstanceOf(JPanel::class.java) + assertThat(gbc.gridy).isEqualTo(4) // row + 2 + assertThat(gbc.weighty).isEqualTo(1.0) + assertThat(gbc.fill).isEqualTo(GridBagConstraints.BOTH) + } + + @Test + fun `multiple addLabeledField calls increment rows correctly`() { + val panel = StackPanelLayoutBuilder.createFormPanel() + val gbc = GridBagConstraints() + + var row = 0 + row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 1", JBLabel("Value 1")) + row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 2", JBLabel("Value 2")) + row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 3", JBLabel("Value 3")) + + assertThat(row).isEqualTo(6) // 3 fields * 2 rows each + assertThat(panel.componentCount).isEqualTo(6) // 3 labels + 3 components + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt new file mode 100644 index 00000000000..36dc6c5e446 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackResourcesPanelTest.kt @@ -0,0 +1,286 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.testFramework.PlatformTestUtil +import com.intellij.testFramework.ProjectRule +import com.intellij.testFramework.runInEdtAndWait +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ListStackResourcesResult +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackResourceSummary +import java.util.concurrent.CompletableFuture +import javax.swing.JScrollPane +import javax.swing.JTable + +class StackResourcesPanelTest { + + @get:Rule + val projectRule = ProjectRule() + + private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345" + private lateinit var mockCfnClient: CfnClientService + private lateinit var mockCoordinator: StackViewCoordinator + + @Before + fun setUp() { + mockCfnClient = mockk() + mockCoordinator = mockk() + mockkObject(CfnClientService) + every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient + every { mockCoordinator.addPollingListener(any(), any()) } returns mockk(relaxed = true) + } + + @After + fun tearDown() { + unmockkObject(CfnClientService) + // Ensure all EDT events are processed to prevent test interference + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + + @Test + fun `loads resources on initialization`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + verify { mockCfnClient.getStackResources(any()) } + } finally { + panel.dispose() + } + } + + @Test + fun `displays resources in table correctly`() { + val resources = listOf( + StackResourceSummary("LogicalId1", "PhysicalId1", "AWS::S3::Bucket", "CREATE_COMPLETE", null), + StackResourceSummary("LogicalId2", null, "AWS::Lambda::Function", "CREATE_IN_PROGRESS", null) + ) + val result = ListStackResourcesResult(resources, null) + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.component).isNotNull() + assertThat(panel.consoleLink.isVisible).isTrue() + assertThat(panel.resourceCountLabel.text).isEqualTo("2 resources") + // Resources should be loaded and displayed in the table + } finally { + panel.dispose() + } + } + + @Test + fun `pagination buttons work correctly`() { + val resources = (1..100).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + } + val result = ListStackResourcesResult(resources, "nextToken123") + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Next") // First page shows "Next", not "Load More" + assertThat(panel.resourceCountLabel.text).isEqualTo("100 resources loaded") + } finally { + panel.dispose() + } + } + + @Test + fun `shows load more button when more data needed from server`() { + val resources = (1..50).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + } + val result = ListStackResourcesResult(resources, "nextToken123") + val futureResult = CompletableFuture.completedFuture(result) + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.nextButton.isEnabled).isTrue() + assertThat(panel.nextButton.text).isEqualTo("Load More") // Exactly 50 resources + nextToken = "Load More" + assertThat(panel.resourceCountLabel.text).isEqualTo("50 resources loaded") + } finally { + panel.dispose() + } + } + + @Test + fun `prevents rapid fire clicks with isLoading flag`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + // Simulate rapid clicks - should only make one call due to isLoading flag + repeat(5) { + val loadResourcesMethod = panel.javaClass.getDeclaredMethod("loadResources").apply { + isAccessible = true + } + loadResourcesMethod.invoke(panel) + } + + try { + verify(exactly = 1) { mockCfnClient.getStackResources(any()) } + } finally { + panel.dispose() + } + } + + @Test + fun `onStackPolled resets to page 1 and reloads data`() { + val initialResources = (1..100).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + } + val refreshedResources = (1..75).map { + StackResourceSummary("NewId$it", "NewPhysical$it", "AWS::Lambda::Function", "UPDATE_COMPLETE", null) + } + + val initialResult = CompletableFuture.completedFuture(ListStackResourcesResult(initialResources, null)) + val refreshResult = CompletableFuture.completedFuture(ListStackResourcesResult(refreshedResources, null)) + + every { mockCfnClient.getStackResources(any()) } returnsMany listOf(initialResult, refreshResult) + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + try { + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Navigate to page 2 first + val loadNextPageMethod = panel.javaClass.getDeclaredMethod("loadNextPage").apply { + isAccessible = true + } + loadNextPageMethod.invoke(panel) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Verify we're on page 2 + assertThat(panel.pageLabel.text).isEqualTo("Page 2") + + // Simulate coordinator calling onStackPolled + panel.onStackPolled() + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Verify polling refresh made new LSP call and reset to page 1 + verify(atLeast = 2) { mockCfnClient.getStackResources(any()) } + assertThat(panel.pageLabel.text).isEqualTo("Page 1") + } finally { + panel.dispose() + } + } + + @Test + fun `makes additional LSP calls when navigating beyond cached data`() { + val firstCall = CompletableFuture.completedFuture( + ListStackResourcesResult( + (1..50).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + }, + "token1" + ) + ) + val secondCall = CompletableFuture.completedFuture( + ListStackResourcesResult( + (51..75).map { + StackResourceSummary("LogicalId$it", "PhysicalId$it", "AWS::S3::Bucket", "CREATE_COMPLETE", null) + }, + null + ) + ) + + every { mockCfnClient.getStackResources(match { it.nextToken == null }) } returns firstCall + every { mockCfnClient.getStackResources(match { it.nextToken == "token1" }) } returns secondCall + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + // Simulate navigation that triggers server call + val loadNextPageMethod = panel.javaClass.getDeclaredMethod("loadNextPage").apply { + isAccessible = true + } + loadNextPageMethod.invoke(panel) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + verify { mockCfnClient.getStackResources(match { it.nextToken == "token1" }) } + } finally { + panel.dispose() + } + } + + @Test + fun `handles API error correctly`() { + val futureResult = CompletableFuture() + every { mockCfnClient.getStackResources(any()) } returns futureResult + + val panel = StackResourcesPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack") + + futureResult.completeExceptionally(RuntimeException("API error")) + + runInEdtAndWait { + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + + try { + assertThat(panel.consoleLink.isVisible).isFalse() + assertThat(panel.prevButton.isEnabled).isFalse() + assertThat(panel.nextButton.isEnabled).isFalse() + assertThat(panel.pageLabel.text).isEqualTo("Page 1") + assertThat(panel.resourceCountLabel.text).isEqualTo("0 resources") + // Verify error message is displayed in table + val tableModel = panel.component.components + .filterIsInstance() + .first().viewport.view as JTable + assertThat(tableModel.getValueAt(0, 0)).asString().contains("Failed to load resources:") + assertThat(tableModel.getValueAt(0, 0)).asString().contains("API error") + } finally { + panel.dispose() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt new file mode 100644 index 00000000000..9331089b657 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt @@ -0,0 +1,237 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class StackViewCoordinatorTest { + + private lateinit var coordinator: StackViewCoordinator + private val testStackArn1 = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack-1/12345" + private val testStackArn2 = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack-2/67890" + + @BeforeEach + fun setUp() { + coordinator = StackViewCoordinator() + } + + @Test + fun `setStack updates state and notifies listeners for specific stack`() { + var notificationCount = 0 + + val listener = object : StackStatusListener { + override fun onStackStatusUpdated() { + notificationCount++ + } + } + + coordinator.addStatusListener(testStackArn1, listener) + coordinator.setStack(testStackArn1, "test-stack-1") + + assertThat(notificationCount).isEqualTo(1) + + val state = coordinator.getStackState(testStackArn1) + assertThat(state?.stackName).isEqualTo("test-stack-1") + assertThat(state?.stackArn).isEqualTo(testStackArn1) + } + + @Test + fun `updateStackStatus only notifies listeners for specific stack`() { + var stack1Updates = 0 + var stack2Updates = 0 + + val listener1 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack1Updates++ + } + } + + val listener2 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack2Updates++ + } + } + + coordinator.addStatusListener(testStackArn1, listener1) + coordinator.addStatusListener(testStackArn2, listener2) + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + + // Reset counters after initial setStack calls + stack1Updates = 0 + stack2Updates = 0 + + // Update stack 1 status + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(stack1Updates).isEqualTo(1) + assertThat(stack2Updates).isEqualTo(0) + + // Update stack 2 status + coordinator.updateStackStatus(testStackArn2, "UPDATE_COMPLETE") + assertThat(stack1Updates).isEqualTo(1) + assertThat(stack2Updates).isEqualTo(1) + + // Same status should not notify + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(stack1Updates).isEqualTo(1) + } + + @Test + fun `multiple stacks can be managed independently`() { + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + + val state1 = coordinator.getStackState(testStackArn1) + val state2 = coordinator.getStackState(testStackArn2) + + assertThat(state1?.stackName).isEqualTo("stack-1") + assertThat(state2?.stackName).isEqualTo("stack-2") + + coordinator.updateStackStatus(testStackArn1, "CREATE_COMPLETE") + coordinator.updateStackStatus(testStackArn2, "UPDATE_IN_PROGRESS") + + val updatedState1 = coordinator.getStackState(testStackArn1) + val updatedState2 = coordinator.getStackState(testStackArn2) + + assertThat(updatedState1?.status).isEqualTo("CREATE_COMPLETE") + assertThat(updatedState2?.status).isEqualTo("UPDATE_IN_PROGRESS") + } + + @Test + fun `listeners only receive notifications for their registered stack`() { + val stack1Updates = mutableListOf() + val stack2Updates = mutableListOf() + + val listener1 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack1Updates.add("updated") + } + } + + val listener2 = object : StackStatusListener { + override fun onStackStatusUpdated() { + stack2Updates.add("updated") + } + } + + coordinator.addStatusListener(testStackArn1, listener1) + coordinator.addStatusListener(testStackArn2, listener2) + + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + coordinator.updateStackStatus(testStackArn1, "COMPLETE") + coordinator.updateStackStatus(testStackArn2, "FAILED") + + // Each listener should receive 2 notifications (setStack + updateStackStatus) + assertThat(stack1Updates).hasSize(2) + assertThat(stack2Updates).hasSize(2) + } + + @Test + fun `new listeners receive immediate notification of current state`() { + coordinator.setStack(testStackArn1, "existing-stack") + coordinator.updateStackStatus(testStackArn1, "CREATE_COMPLETE") + + var notificationCount = 0 + + val listener = object : StackStatusListener { + override fun onStackStatusUpdated() { + notificationCount++ + } + } + + // Listener should immediately receive current state + coordinator.addStatusListener(testStackArn1, listener) + + assertThat(notificationCount).isEqualTo(1) + } + + @Test + fun `removeStack cleans up state and listeners`() { + coordinator.setStack(testStackArn1, "stack-1") + coordinator.addStatusListener( + testStackArn1, + object : StackStatusListener { + override fun onStackStatusUpdated() {} + } + ) + + assertThat(coordinator.getStackState(testStackArn1)).isNotNull() + + coordinator.removeStack(testStackArn1) + + assertThat(coordinator.getStackState(testStackArn1)).isNull() + } + + @Test + fun `listener disposal removes listener for specific stack`() { + var notificationCount = 0 + + val listener = object : StackStatusListener { + override fun onStackStatusUpdated() { + notificationCount++ + } + } + + val disposable = coordinator.addStatusListener(testStackArn1, listener) + coordinator.setStack(testStackArn1, "test") + assertThat(notificationCount).isEqualTo(1) + + disposable.dispose() + coordinator.setStack(testStackArn1, "test-updated") + assertThat(notificationCount).isEqualTo(1) // Should not increment + } + + @Test + fun `updateStackStatus notifies both status and polling listeners`() { + var statusUpdates = 0 + var pollingUpdates = 0 + + val statusListener = object : StackStatusListener { + override fun onStackStatusUpdated() { + statusUpdates++ + } + } + + val pollingListener = object : StackPollingListener { + override fun onStackPolled() { + pollingUpdates++ + } + } + + coordinator.addStatusListener(testStackArn1, statusListener) + coordinator.addPollingListener(testStackArn1, pollingListener) + coordinator.setStack(testStackArn1, "test-stack") + + // Reset counters after initial setStack + statusUpdates = 0 + pollingUpdates = 0 + + // Status change should notify both + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(statusUpdates).isEqualTo(1) + assertThat(pollingUpdates).isEqualTo(1) + + // Same status should only notify polling listeners + coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS") + assertThat(statusUpdates).isEqualTo(1) // No change + assertThat(pollingUpdates).isEqualTo(2) // Always notified + } + + @Test + fun `dispose clears all stacks and listeners`() { + coordinator.setStack(testStackArn1, "stack-1") + coordinator.setStack(testStackArn2, "stack-2") + + assertThat(coordinator.getStackState(testStackArn1)).isNotNull() + assertThat(coordinator.getStackState(testStackArn2)).isNotNull() + + coordinator.dispose() + + assertThat(coordinator.getStackState(testStackArn1)).isNull() + assertThat(coordinator.getStackState(testStackArn2)).isNull() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt new file mode 100644 index 00000000000..eda4360946a --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt @@ -0,0 +1,156 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views + +import com.intellij.ui.JBColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class UtilsTest { + + @Nested + inner class StackStatusUtilsTest { + + @Test + fun `getStatusColors returns green for COMPLETE statuses`() { + val testCases = listOf( + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "DELETE_COMPLETE" + ) + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isEqualTo(JBColor.GREEN) + assertThat(fgColor).isEqualTo(JBColor.BLACK) + } + } + + @Test + fun `getStatusColors returns red for FAILED and ROLLBACK statuses`() { + val testCases = listOf( + "CREATE_FAILED", + "UPDATE_FAILED", + "ROLLBACK_COMPLETE", + "UPDATE_ROLLBACK_COMPLETE" + ) + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isEqualTo(JBColor.RED) + assertThat(fgColor).isEqualTo(JBColor.BLACK) + } + } + + @Test + fun `getStatusColors returns yellow for PROGRESS statuses`() { + val testCases = listOf( + "CREATE_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "DELETE_IN_PROGRESS" + ) + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isEqualTo(JBColor.YELLOW) + assertThat(fgColor).isEqualTo(JBColor.BLACK) + } + } + + @Test + fun `getStatusColors returns null for unknown statuses`() { + val testCases = listOf("UNKNOWN_STATUS", "", "RANDOM_TEXT") + + testCases.forEach { status -> + val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status) + assertThat(bgColor).isNull() + assertThat(fgColor).isNull() + } + } + + @Test + fun `isInTransientState returns true for IN_PROGRESS statuses`() { + val testCases = listOf( + "CREATE_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "DELETE_IN_PROGRESS", + "UPDATE_CLEANUP_IN_PROGRESS" + ) + + testCases.forEach { status -> + assertThat(StackStatusUtils.isInTransientState(status)).isTrue() + } + } + + @Test + fun `isInTransientState returns false for terminal statuses`() { + val testCases = listOf( + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "CREATE_FAILED", + "ROLLBACK_COMPLETE" + ) + + testCases.forEach { status -> + assertThat(StackStatusUtils.isInTransientState(status)).isFalse() + } + } + } + + @Nested + inner class StackDateFormatterTest { + + @Test + fun `formatDate formats valid ISO date string`() { + val isoDate = "2024-01-15T10:30:45Z" + val result = StackDateFormatter.formatDate(isoDate) + + // Should format to d/M/yyyy, h:mm:ss a pattern (timezone-dependent) + assertThat(result).isNotNull() + assertThat(checkNotNull(result)).contains("15/1/2024") + assertThat(result).satisfiesAnyOf( + { assertThat(it).contains("AM") }, + { assertThat(it).contains("PM") } + ) + assertThat(result).contains(":30:45") + } + + @Test + fun `formatDate formats date with milliseconds`() { + val isoDate = "2024-12-25T23:59:59.123Z" + val result = StackDateFormatter.formatDate(isoDate) + + assertThat(result).isNotNull() + assertThat(checkNotNull(result)).contains("25/12/2024") + assertThat(result).satisfiesAnyOf( + { assertThat(it).contains("AM") }, + { assertThat(it).contains("PM") } + ) + assertThat(result).contains(":59:59") + } + + @Test + fun `formatDate returns null for invalid date`() { + val invalidDate = "not-a-date" + val result = StackDateFormatter.formatDate(invalidDate) + + assertThat(result).isNull() + } + + @Test + fun `formatDate returns null for empty string`() { + val result = StackDateFormatter.formatDate("") + assertThat(result).isNull() + } + + @Test + fun `formatDate returns null for malformed ISO date`() { + val malformedDate = "2024-13-45T25:70:80Z" + val result = StackDateFormatter.formatDate(malformedDate) + + assertThat(result).isNull() + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt new file mode 100644 index 00000000000..681aa6a8b3d --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/ChangeSetDriftTest.kt @@ -0,0 +1,182 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DriftInfo +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChange +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceChangeDetail +import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.ResourceTargetDefinition + +class ChangeSetDriftTest { + + @Test + fun `no drift returns original json unchanged`() { + val rc = ResourceChange(action = "Modify", logicalResourceId = "MyBucket") + val json = """{ "BucketName": "test" }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `deleted resource prepends comment`() { + val rc = ResourceChange(resourceDriftStatus = "DELETED") + val json = """{ "Key": "value" }""" + val result = annotateDriftInJson(rc, json) + assertThat(result).startsWith("// \u26A0\uFE0F Resource deleted out-of-band") + assertThat(result).endsWith(json) + } + + @Test + fun `drift annotation appended to correct property line`() { + val rc = ResourceChange( + action = "Modify", + logicalResourceId = "Fn", + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + name = "MemorySize", + path = "/Properties/MemorySize", + drift = DriftInfo(previousValue = "128", actualValue = "256"), + ) + ) + ) + ) + val json = """{ + "Properties": { + "MemorySize": 128, + "Runtime": "python3.12" + } +}""" + val result = annotateDriftInJson(rc, json) + val lines = result.lines() + val memoryLine = lines.first { it.contains("MemorySize") } + assertThat(memoryLine).contains("\u2190 \u26A0\uFE0F Drifted (Live AWS: 256)") + val runtimeLine = lines.first { it.contains("Runtime") } + assertThat(runtimeLine).doesNotContain("Drifted") + } + + @Test + fun `drift with LiveResourceDrift fallback`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + name = "Timeout", + path = "/Properties/Timeout", + liveResourceDrift = DriftInfo(previousValue = "30", actualValue = "60"), + ) + ) + ) + ) + val json = """{ + "Properties": { + "Timeout": 30 + } +}""" + val result = annotateDriftInJson(rc, json) + assertThat(result).contains("Drifted (Live AWS: 60)") + } + + @Test + fun `drift with null actualValue is ignored`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Properties/MemorySize", + drift = DriftInfo(previousValue = "128", actualValue = null), + ) + ) + ) + ) + val json = """{ "Properties": { "MemorySize": 128 } }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `drift with missing path is ignored`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + drift = DriftInfo(previousValue = "128", actualValue = "256"), + ) + ) + ) + ) + val json = """{ "MemorySize": 128 }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `path with numeric index skips array indices`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Tags/0/Value", + drift = DriftInfo(previousValue = "old", actualValue = "new"), + ) + ) + ) + ) + val json = """{ + "Tags": [ + { + "Key": "env", + "Value": "prod" + } + ] +}""" + val result = annotateDriftInJson(rc, json) + val valueLine = result.lines().first { it.contains("\"Value\"") } + assertThat(valueLine).contains("Drifted (Live AWS: new)") + } + + @Test + fun `unresolvable path leaves json unchanged`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/NonExistent/Property", + drift = DriftInfo(previousValue = "a", actualValue = "b"), + ) + ) + ) + ) + val json = """{ "Other": "value" }""" + assertThat(annotateDriftInJson(rc, json)).isEqualTo(json) + } + + @Test + fun `multiple drifted properties annotated independently`() { + val rc = ResourceChange( + details = listOf( + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Properties/MemorySize", + drift = DriftInfo(previousValue = "128", actualValue = "256"), + ) + ), + ResourceChangeDetail( + target = ResourceTargetDefinition( + path = "/Properties/Timeout", + drift = DriftInfo(previousValue = "30", actualValue = "60"), + ) + ), + ) + ) + val json = """{ + "Properties": { + "MemorySize": 128, + "Timeout": 30 + } +}""" + val result = annotateDriftInJson(rc, json) + assertThat(result).contains("Drifted (Live AWS: 256)") + assertThat(result).contains("Drifted (Live AWS: 60)") + } +} diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt new file mode 100644 index 00000000000..1cb321c32f5 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt @@ -0,0 +1,62 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.ui + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class UtilsTest { + + @Test + fun `generateUrl creates correct AWS console URL`() { + val stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/" + + "12345678-1234-1234-1234-123456789012" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aus-east-1%3A123456789012%3Astack%2Fmy-stack%2F" + + "12345678-1234-1234-1234-123456789012" + assertThat(result).isEqualTo(expectedUrl) + } + + @Test + fun `generateUrl handles special characters in stack name`() { + val stackId = "arn:aws:cloudformation:us-west-2:123456789012:stack/" + + "my-stack-with-dashes_and_underscores/12345" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2F" + + "my-stack-with-dashes_and_underscores%2F12345" + assertThat(result).isEqualTo(expectedUrl) + } + + @Test + fun `generateUrl handles different regions`() { + val stackId = "arn:aws:cloudformation:eu-west-1:123456789012:stack/test-stack/abcdef" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aeu-west-1%3A123456789012%3Astack%2Ftest-stack%2Fabcdef" + assertThat(result).isEqualTo(expectedUrl) + } + + @Test + fun `generateUrl handles empty string`() { + val result = ConsoleUrlGenerator.generateUrl("") + assertThat(result).isEqualTo("https://console.aws.amazon.com/go/view?arn=") + } + + @Test + fun `generateUrl handles spaces and special characters`() { + val stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/" + + "stack with spaces & symbols/12345" + val result = ConsoleUrlGenerator.generateUrl(stackId) + + val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" + + "arn%3Aaws%3Acloudformation%3Aus-east-1%3A123456789012%3Astack%2F" + + "stack+with+spaces+%26+symbols%2F12345" + assertThat(result).isEqualTo(expectedUrl) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt new file mode 100644 index 00000000000..219967bacbb --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt @@ -0,0 +1,48 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.system.CpuArch +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class LspUtilsTest { + + @Test + fun `getToolkitsCacheRoot returns platform-appropriate path`() { + val cacheRoot = getToolkitsCacheRoot() + + assertThat(cacheRoot.toString()).contains("aws") + assertThat(cacheRoot.toString()).contains("toolkits") + + when { + SystemInfo.isMac -> assertThat(cacheRoot.toString()).contains("Library/Caches") + SystemInfo.isWindows -> assertThat(cacheRoot.toString()).doesNotContain(".cache") + else -> assertThat(cacheRoot.toString()).contains(".cache") + } + } + + @Test + fun `getCurrentOS returns correct platform string`() { + val os = getCurrentOS() + + when { + SystemInfo.isWindows -> assertThat(os).isEqualTo("windows") + SystemInfo.isMac -> assertThat(os).isEqualTo("darwin") + else -> assertThat(os).isEqualTo("linux") + } + } + + @Test + fun `getCurrentArchitecture returns correct architecture string`() { + val arch = getCurrentArchitecture() + + when (CpuArch.CURRENT) { + CpuArch.X86_64 -> assertThat(arch).isEqualTo("x64") + CpuArch.ARM64 -> assertThat(arch).isEqualTo("arm64") + else -> assertThat(arch).isEqualTo("unknown") + } + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt new file mode 100644 index 00000000000..8c28aa64f7f --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/CfnTelemetryPromptStateTest.kt @@ -0,0 +1,135 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +import com.intellij.testFramework.ApplicationRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import software.aws.toolkits.jetbrains.settings.CfnLspSettings + +class CfnTelemetryPromptStateTest { + + @Rule + @JvmField + val applicationRule = ApplicationRule() + + private lateinit var promptState: CfnTelemetryPromptState + + @Before + fun setUp() { + promptState = CfnTelemetryPromptState.getInstance() + promptState.hasResponded = false + promptState.lastPromptDate = 0L + } + + @Test + fun `default state has not responded and no prompt date`() { + val state = CfnTelemetryPromptState.State() + assertThat(state.hasResponded).isFalse() + assertThat(state.lastPromptDate).isEqualTo(0L) + } + + @Test + fun `hasResponded persists through state`() { + promptState.hasResponded = true + assertThat(promptState.getState().hasResponded).isTrue() + } + + @Test + fun `lastPromptDate persists through state`() { + val now = System.currentTimeMillis() + promptState.lastPromptDate = now + assertThat(promptState.getState().lastPromptDate).isEqualTo(now) + } + + @Test + fun `loadState restores values`() { + val saved = CfnTelemetryPromptState.State(hasResponded = true, lastPromptDate = 12345L) + promptState.loadState(saved) + + assertThat(promptState.hasResponded).isTrue() + assertThat(promptState.lastPromptDate).isEqualTo(12345L) + } + + @Test + fun `allow choice enables telemetry and marks permanent`() { + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = true + promptState.lastPromptDate = System.currentTimeMillis() + settings.isTelemetryEnabled = true + + assertThat(promptState.hasResponded).isTrue() + assertThat(promptState.lastPromptDate).isGreaterThan(0L) + assertThat(settings.isTelemetryEnabled).isTrue() + + settings.isTelemetryEnabled = false + } + + @Test + fun `never choice disables telemetry and marks permanent`() { + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = true + promptState.lastPromptDate = System.currentTimeMillis() + settings.isTelemetryEnabled = false + + assertThat(promptState.hasResponded).isTrue() + assertThat(settings.isTelemetryEnabled).isFalse() + } + + @Test + fun `not now choice disables telemetry without marking permanent`() { + val settings = CfnLspSettings.getInstance() + + promptState.hasResponded = false + promptState.lastPromptDate = System.currentTimeMillis() + settings.isTelemetryEnabled = false + + assertThat(promptState.hasResponded).isFalse() + assertThat(promptState.lastPromptDate).isGreaterThan(0L) + assertThat(settings.isTelemetryEnabled).isFalse() + + settings.isTelemetryEnabled = false + } + + @Test + fun `should prompt when no prior interaction`() { + assertThat(promptState.hasResponded).isFalse() + assertThat(promptState.lastPromptDate).isEqualTo(0L) + + val shouldPrompt = !promptState.hasResponded && + (promptState.lastPromptDate == 0L || System.currentTimeMillis() - promptState.lastPromptDate >= 30L * 24 * 60 * 60 * 1000) + assertThat(shouldPrompt).isTrue() + } + + @Test + fun `should not prompt when permanently responded`() { + promptState.hasResponded = true + + assertThat(!promptState.hasResponded).isFalse() + } + + @Test + fun `should not prompt within 30 day window`() { + promptState.lastPromptDate = System.currentTimeMillis() - (20L * 24 * 60 * 60 * 1000) + + val thirtyDaysMs = 30L * 24 * 60 * 60 * 1000 + val withinWindow = promptState.lastPromptDate != 0L && + System.currentTimeMillis() - promptState.lastPromptDate < thirtyDaysMs + assertThat(withinWindow).isTrue() + } + + @Test + fun `should prompt after 30 days elapsed`() { + promptState.lastPromptDate = System.currentTimeMillis() - (31L * 24 * 60 * 60 * 1000) + + val thirtyDaysMs = 30L * 24 * 60 * 60 * 1000 + val withinWindow = promptState.lastPromptDate != 0L && + System.currentTimeMillis() - promptState.lastPromptDate < thirtyDaysMs + assertThat(withinWindow).isFalse() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt new file mode 100644 index 00000000000..0019feb21a9 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspExceptionTest.kt @@ -0,0 +1,50 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class CfnLspExceptionTest { + + @Test + fun `exception contains message and error code`() { + val exception = CfnLspException( + "Test error message", + CfnLspException.ErrorCode.DOWNLOAD_FAILED + ) + + assertThat(exception.message).isEqualTo("Test error message") + assertThat(exception.errorCode).isEqualTo(CfnLspException.ErrorCode.DOWNLOAD_FAILED) + assertThat(exception.cause).isNull() + } + + @Test + fun `exception contains cause when provided`() { + val cause = RuntimeException("Root cause") + val exception = CfnLspException( + "Wrapper message", + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED, + cause + ) + + assertThat(exception.message).isEqualTo("Wrapper message") + assertThat(exception.errorCode).isEqualTo(CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED) + assertThat(exception.cause).isEqualTo(cause) + } + + @Test + fun `error codes cover all failure scenarios`() { + val errorCodes = CfnLspException.ErrorCode.values() + + assertThat(errorCodes).containsExactlyInAnyOrder( + CfnLspException.ErrorCode.MANIFEST_FETCH_FAILED, + CfnLspException.ErrorCode.NO_COMPATIBLE_VERSION, + CfnLspException.ErrorCode.DOWNLOAD_FAILED, + CfnLspException.ErrorCode.EXTRACTION_FAILED, + CfnLspException.ErrorCode.NODE_NOT_FOUND, + CfnLspException.ErrorCode.HASH_VERIFICATION_FAILED + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt new file mode 100644 index 00000000000..31f1aecc745 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstallerTest.kt @@ -0,0 +1,208 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.nio.file.Files +import java.nio.file.Path + +class CfnLspInstallerTest { + + @Rule + @JvmField + val tempFolder = TemporaryFolder() + + @Test + fun `findCachedServer returns null when storage dir does not exist`() { + val installer = installerWithDir("non-existent") + assertThat(installer.findCachedServerForTest()).isNull() + } + + @Test + fun `findCachedServer returns null when no server files present`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + Files.createDirectories(storageDir.resolve("1.2.0")) + + assertThat(CfnLspInstaller(storageDir).findCachedServerForTest()).isNull() + } + + @Test + fun `findCachedServer returns highest compatible version`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.0.0") + createServerVersion(storageDir, "1.2.0") + createServerVersion(storageDir, "1.4.0") + + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.4.0") + } + + @Test + fun `findCachedServer uses semver not lexicographic ordering`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.9.0") + createServerVersion(storageDir, "1.10.0") + + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.10.0") + } + + @Test + fun `findCachedServer excludes versions outside supported range`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + createServerVersion(storageDir, "2.0.0") + createServerVersion(storageDir, "3.0.0") + + // Default range is <2.0.0 + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.4.0") + } + + @Test + fun `findCachedServer returns null when all versions outside range`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "2.0.0") + createServerVersion(storageDir, "3.0.0") + + assertThat(CfnLspInstaller(storageDir).findCachedServerForTest()).isNull() + } + + @Test + fun `findCachedServer skips directories with unparseable names`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "not-a-version") + createServerVersion(storageDir, "1.2.0") + + val result = CfnLspInstaller(storageDir).findCachedServerForTest() + assertThat(result?.parent?.fileName.toString()).isEqualTo("1.2.0") + } + + // --- cleanupOldVersions --- + + @Test + fun `cleanupOldVersions keeps current version and one fallback`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.0.0") + createServerVersion(storageDir, "1.2.0") + createServerVersion(storageDir, "1.4.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() // current + assertThat(Files.exists(storageDir.resolve("1.2.0"))).isTrue() // fallback + assertThat(Files.exists(storageDir.resolve("1.0.0"))).isFalse() // removed + } + + @Test + fun `cleanupOldVersions removes versions outside supported range`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + createServerVersion(storageDir, "2.0.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() + assertThat(Files.exists(storageDir.resolve("2.0.0"))).isFalse() + } + + @Test + fun `cleanupOldVersions preserves only current when no valid fallback`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() + } + + @Test + fun `cleanupOldVersions handles non-existent storage dir gracefully`() { + val installer = installerWithDir("non-existent") + // Should not throw + installer.cleanupOldVersionsForTest("1.0.0") + } + + @Test + fun `cleanupOldVersions picks highest compatible version as fallback`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.0.0") + createServerVersion(storageDir, "1.1.0") + createServerVersion(storageDir, "1.2.0") + createServerVersion(storageDir, "1.3.0") + createServerVersion(storageDir, "1.4.0") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() // current + assertThat(Files.exists(storageDir.resolve("1.3.0"))).isTrue() // fallback (highest below current) + assertThat(Files.exists(storageDir.resolve("1.2.0"))).isFalse() + assertThat(Files.exists(storageDir.resolve("1.1.0"))).isFalse() + assertThat(Files.exists(storageDir.resolve("1.0.0"))).isFalse() + } + + @Test + fun `cleanupOldVersions skips unparseable directory names`() { + val storageDir = tempFolder.newFolder("lsp-storage").toPath() + createServerVersion(storageDir, "1.4.0") + createServerVersion(storageDir, "temp-download") + + CfnLspInstaller(storageDir).cleanupOldVersionsForTest("1.4.0") + + assertThat(Files.exists(storageDir.resolve("1.4.0"))).isTrue() + // unparseable dirs get cleaned up since they're not in the keep set + assertThat(Files.exists(storageDir.resolve("temp-download"))).isFalse() + } + + // --- hash utilities --- + + @Test + fun `parseHashString extracts algorithm and hash`() { + assertThat(CfnLspInstaller.parseHashString("sha256:abc123def456")) + .isEqualTo("sha256" to "abc123def456") + } + + @Test + fun `parseHashString returns null for invalid format`() { + assertThat(CfnLspInstaller.parseHashString("invalidhash")).isNull() + } + + @Test + fun `computeHash calculates sha256 correctly`() { + val hash = CfnLspInstaller.computeHash("test data".toByteArray(), "sha256") + assertThat(hash).isEqualTo("916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9") + } + + @Test + fun `computeHash calculates sha384 correctly`() { + val hash = CfnLspInstaller.computeHash("test data".toByteArray(), "sha384") + assertThat(hash).hasSize(96) // SHA-384 produces 96 hex characters + } + + // --- helpers --- + + private fun createServerVersion(storageDir: Path, version: String) { + val dir = storageDir.resolve(version) + Files.createDirectories(dir) + Files.createFile(dir.resolve(CfnLspServerConfig.SERVER_FILE)) + } + + private fun installerWithDir(name: String) = + CfnLspInstaller(tempFolder.root.toPath().resolve(name)) + + private fun CfnLspInstaller.findCachedServerForTest(): Path? { + val method = this::class.java.getDeclaredMethod("findCachedServer") + method.isAccessible = true + return method.invoke(this) as? Path + } + + private fun CfnLspInstaller.cleanupOldVersionsForTest(currentVersion: String) { + val method = this::class.java.getDeclaredMethod("cleanupOldVersions", String::class.java) + method.isAccessible = true + method.invoke(this, currentVersion) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt new file mode 100644 index 00000000000..530ad328b63 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapterTest.kt @@ -0,0 +1,305 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.Test + +class GitHubManifestAdapterTest { + + @Test + fun `ManifestVersion data class holds correct values`() { + val version = ManifestVersion( + serverVersion = "1.3.0", + latest = true, + isDelisted = false, + targets = emptyList() + ) + + assertThat(version.serverVersion).isEqualTo("1.3.0") + assertThat(version.latest).isTrue() + assertThat(version.isDelisted).isFalse() + assertThat(version.targets).isEmpty() + } + + @Test + fun `ManifestTarget data class holds correct values`() { + val target = ManifestTarget( + platform = "darwin", + arch = "arm64", + nodejs = "22", + contents = listOf( + ManifestContent( + filename = "server.zip", + url = "https://example.com/server.zip", + bytes = 50000000 + ) + ) + ) + + assertThat(target.platform).isEqualTo("darwin") + assertThat(target.arch).isEqualTo("arm64") + assertThat(target.nodejs).isEqualTo("22") + assertThat(target.contents).hasSize(1) + assertThat(target.contents[0].filename).isEqualTo("server.zip") + } + + @Test + fun `ServerRelease data class holds correct values`() { + val release = ServerRelease( + version = "1.3.0", + downloadUrl = "https://example.com/download.zip", + filename = "server.zip", + size = 50000000, + hashes = listOf("sha256:abc123") + ) + + assertThat(release.version).isEqualTo("1.3.0") + assertThat(release.downloadUrl).isEqualTo("https://example.com/download.zip") + assertThat(release.filename).isEqualTo("server.zip") + assertThat(release.size).isEqualTo(50000000) + assertThat(release.hashes).containsExactly("sha256:abc123") + } + + @Test + fun `remapLegacyLinux replaces linux with linuxglib2_28`() { + val versions = listOf( + ManifestVersion( + serverVersion = "1.0.0", + targets = listOf( + ManifestTarget("linux", "x64", "22", listOf(ManifestContent("a.zip", "url", emptyList(), 100))), + ManifestTarget("linuxglib2.28", "x64", "18", listOf(ManifestContent("b.zip", "url", emptyList(), 100))), + ManifestTarget("darwin", "x64", "22", listOf(ManifestContent("c.zip", "url", emptyList(), 100))), + ) + ) + ) + + val remapped = GitHubManifestAdapter.remapLegacyLinux(versions) + + assertThat(remapped[0].targets.map { it.platform }).containsExactlyInAnyOrder("linux", "darwin") + assertThat(remapped[0].targets.first { it.platform == "linux" }.nodejs).isEqualTo("18") + } + + @Test + fun `remapLegacyLinux preserves version without legacy target`() { + val versions = listOf( + ManifestVersion( + serverVersion = "1.0.0", + targets = listOf( + ManifestTarget("linux", "x64", "22", listOf(ManifestContent("a.zip", "url", emptyList(), 100))), + ManifestTarget("darwin", "x64", "22", listOf(ManifestContent("c.zip", "url", emptyList(), 100))), + ) + ) + ) + + val remapped = GitHubManifestAdapter.remapLegacyLinux(versions) + + assertThat(remapped[0].targets.map { it.platform }).containsExactlyInAnyOrder("linux", "darwin") + } + + @Test + fun `parseManifest prefers version marked as latest`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("1.4.0", true, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest falls back to semver sort when no latest flag`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + // No version marked as latest — should pick highest by semver + val manifest = buildManifestJson( + "prod", + listOf("1.2.0", "1.4.0", "1.0.0", "1.3.1") + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest skips latest if outside version range`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("2.0.0", true, false), + Triple("1.4.0", false, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest skips latest if delisted`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("1.4.0", true, true), + Triple("1.3.0", false, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.3.0") + } + + @Test + fun `parseManifest respects version range and excludes 2_x`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJson( + "prod", + listOf("2.1.0", "2.0.0", "1.4.0", "1.2.0") + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0") + } + + @Test + fun `parseManifest skips delisted versions`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJsonFull( + "prod", + listOf( + Triple("1.4.0", false, true), + Triple("1.3.0", false, false), + Triple("1.2.0", false, false), + ) + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.3.0") + } + + @Test + fun `parseManifest errors when no compatible version exists`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<1.0.0"), + ) + + val manifest = buildManifestJson("prod", listOf("1.4.0", "1.2.0")) + + assertThatThrownBy { adapter.parseManifest(manifest) } + .hasMessageContaining("No compatible version found") + } + + @Test + fun `parseManifest handles beta versions correctly`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.BETA, + versionRange = SemVerRange.parse("<2.0.0"), + ) + + val manifest = buildManifestJson( + "beta", + listOf("1.4.0-beta", "1.2.0-beta") + ) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("1.4.0-beta") + } + + @Test + fun `parseManifest numeric sort not lexicographic`() { + val adapter = GitHubManifestAdapter( + environment = CfnLspEnvironment.PROD, + versionRange = SemVerRange.parse("<100.0.0"), + ) + + val manifest = buildManifestJson("prod", listOf("9.0.0", "10.0.0", "2.0.0")) + + val result = adapter.parseManifest(manifest) + assertThat(result.version).isEqualTo("10.0.0") + } + + // --- helpers --- + + private fun buildManifestJson(env: String, versions: List): String = + buildManifestJsonFull(env, versions.map { Triple(it, false, false) }) + + private fun buildManifestJsonFull( + env: String, + versions: List>, + ): String { + val mapper = jacksonObjectMapper() + val versionObjects = versions.map { (v, latest, delisted) -> + mapOf( + "serverVersion" to v, + "latest" to latest, + "isDelisted" to delisted, + "targets" to listOf(currentPlatformTarget(v)) + ) + } + return mapper.writeValueAsString(mapOf(env to versionObjects)) + } + + private fun currentPlatformTarget(version: String): Map { + val os = System.getProperty("os.name").lowercase().let { + when { + it.contains("mac") -> "darwin" + it.contains("win") -> "windows" + else -> "linux" + } + } + val arch = System.getProperty("os.arch").lowercase().let { + when { + it.contains("aarch64") || it.contains("arm64") -> "arm64" + else -> "x64" + } + } + return mapOf( + "platform" to os, + "arch" to arch, + "contents" to listOf( + mapOf( + "filename" to "server-$version.zip", + "url" to "https://example.com/server-$version.zip", + "hashes" to emptyList(), + "bytes" to 50000000 + ) + ) + ) + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt new file mode 100644 index 00000000000..ec2583310f8 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/LegacyLinuxDetectorTest.kt @@ -0,0 +1,60 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class LegacyLinuxDetectorTest { + + @Test + fun `parseGlibcxxVersions extracts versions from strings output`() { + val output = """ + GLIBCXX_3.4 + GLIBCXX_3.4.1 + GLIBCXX_3.4.29 + GLIBCXX_3.4.30 + some other text + """.trimIndent() + + val detector = LegacyLinuxDetector() + val versions = detector.parseGlibcxxVersions(output) + + assertThat(versions).containsExactlyInAnyOrder( + listOf(3, 4), + listOf(3, 4, 1), + listOf(3, 4, 29), + listOf(3, 4, 30) + ) + } + + @Test + fun `parseGlibcxxVersions returns empty for no matches`() { + val detector = LegacyLinuxDetector() + val versions = detector.parseGlibcxxVersions("no versions here") + + assertThat(versions).isEmpty() + } + + @Test + fun `compareVersions returns negative when first is less`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4, 28), listOf(3, 4, 29))).isNegative() + } + + @Test + fun `compareVersions returns positive when first is greater`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4, 30), listOf(3, 4, 29))).isPositive() + } + + @Test + fun `compareVersions returns zero when equal`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4, 29), listOf(3, 4, 29))).isZero() + } + + @Test + fun `compareVersions handles different length versions`() { + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4), listOf(3, 4, 0))).isZero() + assertThat(LegacyLinuxDetector.compareVersions(listOf(3, 4), listOf(3, 4, 1))).isNegative() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt new file mode 100644 index 00000000000..6b22ebc6213 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/services/cfnlsp/server/SemVerTest.kt @@ -0,0 +1,136 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp.server + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class SemVerTest { + + @Test + fun `parse standard version`() { + val v = checkNotNull(SemVer.parse("1.4.0")) + assertThat(v.major).isEqualTo(1) + assertThat(v.minor).isEqualTo(4) + assertThat(v.patch).isEqualTo(0) + assertThat(v.prerelease).isEmpty() + } + + @Test + fun `parse version with v prefix`() { + val v = checkNotNull(SemVer.parse("v1.4.0")) + assertThat(v.major).isEqualTo(1) + assertThat(v.minor).isEqualTo(4) + } + + @Test + fun `parse version with prerelease`() { + val v = checkNotNull(SemVer.parse("1.4.0-beta")) + assertThat(v.prerelease).containsExactly("beta") + } + + @Test + fun `parse returns null for invalid input`() { + assertThat(SemVer.parse("not-a-version")).isNull() + assertThat(SemVer.parse("1.2")).isNull() + assertThat(SemVer.parse("")).isNull() + assertThat(SemVer.parse("abc.def.ghi")).isNull() + } + + @Test + fun `comparison - major version difference`() { + assertThat(checkNotNull(SemVer.parse("2.0.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.9.9"))) + } + + @Test + fun `comparison - minor version difference`() { + assertThat(checkNotNull(SemVer.parse("1.5.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.4.9"))) + } + + @Test + fun `comparison - patch version difference`() { + assertThat(checkNotNull(SemVer.parse("1.4.1"))).isGreaterThan(checkNotNull(SemVer.parse("1.4.0"))) + } + + @Test + fun `comparison - release beats prerelease`() { + assertThat(checkNotNull(SemVer.parse("1.4.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.4.0-beta"))) + } + + @Test + fun `comparison - equal versions`() { + assertThat(checkNotNull(SemVer.parse("1.4.0"))).isEqualByComparingTo(checkNotNull(SemVer.parse("1.4.0"))) + } + + @Test + fun `comparison - v prefix ignored`() { + assertThat(checkNotNull(SemVer.parse("v1.4.0"))).isEqualByComparingTo(checkNotNull(SemVer.parse("1.4.0"))) + } + + @Test + fun `comparison - 10 is greater than 9 (not lexicographic)`() { + assertThat(checkNotNull(SemVer.parse("10.0.0"))).isGreaterThan(checkNotNull(SemVer.parse("9.0.0"))) + assertThat(checkNotNull(SemVer.parse("1.10.0"))).isGreaterThan(checkNotNull(SemVer.parse("1.9.0"))) + } + + @Test + fun `sorting produces correct order`() { + val versions = listOf("1.0.0", "1.4.0", "1.2.0", "1.3.1", "1.1.0") + .map { checkNotNull(SemVer.parse(it)) } + .sortedDescending() + .map { "${it.major}.${it.minor}.${it.patch}" } + + assertThat(versions).containsExactly("1.4.0", "1.3.1", "1.2.0", "1.1.0", "1.0.0") + } + + @Test + fun `sorting with prereleases`() { + val versions = listOf("1.4.0", "1.4.0-beta", "1.3.1", "1.3.1-beta") + .map { checkNotNull(SemVer.parse(it)) } + .sortedDescending() + .map { + "${it.major}.${it.minor}.${it.patch}" + + if (it.prerelease.isNotEmpty()) "-${it.prerelease.joinToString("-")}" else "" + } + + assertThat(versions).containsExactly("1.4.0", "1.4.0-beta", "1.3.1", "1.3.1-beta") + } +} + +class SemVerRangeTest { + + @Test + fun `less than range`() { + val range = SemVerRange.parse("<2.0.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.4.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.99.99")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.0")))).isFalse() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.1")))).isFalse() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("3.0.0")))).isFalse() + } + + @Test + fun `less than range includes prereleases`() { + val range = SemVerRange.parse("<2.0.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.4.0-beta")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.0-beta")))).isFalse() + } + + @Test + fun `greater than or equal range`() { + val range = SemVerRange.parse(">=1.2.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.2.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.4.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.1.0")))).isFalse() + } + + @Test + fun `combined range`() { + val range = SemVerRange.parse(">=1.0.0 <2.0.0") + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.0.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("1.99.0")))).isTrue() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("0.9.0")))).isFalse() + assertThat(range.satisfiedBy(checkNotNull(SemVer.parse("2.0.0")))).isFalse() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt new file mode 100644 index 00000000000..b881b842703 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/settings/CfnLspSettingsTest.kt @@ -0,0 +1,71 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.testFramework.ApplicationRule +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test + +class CfnLspSettingsTest { + + @Rule + @JvmField + val applicationRule = ApplicationRule() + + @Test + fun `default settings have expected values`() { + val settings = CfnLspSettings.getInstance() + + assertThat(settings.nodeRuntimePath).isEmpty() + assertThat(settings.isTelemetryEnabled).isFalse() + assertThat(settings.isHoverEnabled).isTrue() + assertThat(settings.isCompletionEnabled).isTrue() + assertThat(settings.maxCompletions).isEqualTo(100) + } + + @Test + fun `cfn-lint default settings`() { + val settings = CfnLspSettings.getInstance() + + assertThat(settings.isCfnLintEnabled).isTrue() + assertThat(settings.cfnLintLintOnChange).isTrue() + assertThat(settings.cfnLintDelayMs).isEqualTo(3000) + assertThat(settings.cfnLintIncludeChecks).isEqualTo("I") + assertThat(settings.cfnLintIncludeExperimental).isFalse() + } + + @Test + fun `cfn-guard default settings`() { + val settings = CfnLspSettings.getInstance() + + assertThat(settings.isCfnGuardEnabled).isTrue() + assertThat(settings.cfnGuardValidateOnChange).isTrue() + assertThat(settings.cfnGuardEnabledRulePacks).isEqualTo("wa-Security-Pillar") + assertThat(settings.cfnGuardRulesFile).isEmpty() + } + + @Test + fun `settings can be modified`() { + val settings = CfnLspSettings.getInstance() + + settings.nodeRuntimePath = "/usr/bin/node" + assertThat(settings.nodeRuntimePath).isEqualTo("/usr/bin/node") + + settings.maxCompletions = 50 + assertThat(settings.maxCompletions).isEqualTo(50) + + // Reset to defaults + settings.nodeRuntimePath = "" + settings.maxCompletions = 100 + } + + @Test + fun `guard rule packs list is populated`() { + assertThat(CfnLspSettings.GUARD_RULE_PACKS).isNotEmpty() + assertThat(CfnLspSettings.GUARD_RULE_PACKS).contains("wa-Security-Pillar") + assertThat(CfnLspSettings.GUARD_RULE_PACKS).contains("hipaa-security") + assertThat(CfnLspSettings.GUARD_RULE_PACKS).contains("cis-aws-benchmark-level-1") + } +} From 3950e542adc24aa3a13375eb8a294ce052335339 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 13 Mar 2026 19:08:18 +0000 Subject: [PATCH 29/44] Updating version to 3.106 --- .changes/3.106.json | 14 ++++++++++++++ ...ature-41a366ff-5293-4924-a772-4096fbcd6c0f.json | 4 ---- ...ature-cdbb737a-4589-4e43-8102-33e8a881db59.json | 4 ---- ...ature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json | 4 ---- CHANGELOG.md | 5 +++++ gradle.properties | 2 +- 6 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 .changes/3.106.json delete mode 100644 .changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json delete mode 100644 .changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json delete mode 100644 .changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json diff --git a/.changes/3.106.json b/.changes/3.106.json new file mode 100644 index 00000000000..c5dcc924b98 --- /dev/null +++ b/.changes/3.106.json @@ -0,0 +1,14 @@ +{ + "date" : "2026-03-13", + "version" : "3.106", + "entries" : [ { + "type" : "feature", + "description" : "Added support for validating and deploying CloudFormation templates to new or existing stacks" + }, { + "type" : "feature", + "description" : "Added intelligent authoring support for CloudFormation templates." + }, { + "type" : "feature", + "description" : "Added support for viewing CloudFormation stack details such as resources and events" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json b/.changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json deleted file mode 100644 index c5a6688067b..00000000000 --- a/.changes/next-release/feature-41a366ff-5293-4924-a772-4096fbcd6c0f.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "feature", - "description" : "Added support for validating and deploying CloudFormation templates to new or existing stacks" -} \ No newline at end of file diff --git a/.changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json b/.changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json deleted file mode 100644 index c1cada1b8d7..00000000000 --- a/.changes/next-release/feature-cdbb737a-4589-4e43-8102-33e8a881db59.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "feature", - "description" : "Added intelligent authoring support for CloudFormation templates." -} \ No newline at end of file diff --git a/.changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json b/.changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json deleted file mode 100644 index bb457a9507c..00000000000 --- a/.changes/next-release/feature-fcf51eb8-f6b7-4b53-aa19-e712e1645d32.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type" : "feature", - "description" : "Added support for viewing CloudFormation stack details such as resources and events" -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 862e2009da1..02a53290edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# _3.106_ (2026-03-13) +- **(Feature)** Added support for validating and deploying CloudFormation templates to new or existing stacks +- **(Feature)** Added intelligent authoring support for CloudFormation templates. +- **(Feature)** Added support for viewing CloudFormation stack details such as resources and events + # _3.105_ (2026-03-06) - **(Removal)** Removed support for 2024.3.x IDEs and Gateway 2025.2 diff --git a/gradle.properties b/gradle.properties index c665d708134..d665d49446f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.106-SNAPSHOT +toolkitVersion=3.106 # Publish Settings publishToken= From a68a066261e21cb57bbd605ad7ed0f1101380280 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 13 Mar 2026 20:43:01 +0000 Subject: [PATCH 30/44] Updating SNAPSHOT version to 3.107-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index d665d49446f..d53541233c6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.106 +toolkitVersion=3.107-SNAPSHOT # Publish Settings publishToken= From d98f436ebcc7d646e132b005f8844c16c9084376 Mon Sep 17 00:00:00 2001 From: Christopher-Neil Mendoza Date: Wed, 18 Mar 2026 17:41:24 -0400 Subject: [PATCH 31/44] fix(cloudformation): modify notification popup title and actions (#6313) --- .../aws/toolkits/resources/MessagesBundle.properties | 7 ++++--- .../jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) 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 cafa95bd934..2eb8a7e943e 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -581,9 +581,10 @@ cloudformation.lsp.error.no_compatible_version=No compatible CloudFormation LSP cloudformation.lsp.error.node_not_found=Node.js not found. Install or configure Node.js for CloudFormation Language Server. cloudformation.lsp.error.title=CloudFormation Language Server cloudformation.lsp.intro.prompt.action.dont_show=Don't show again -cloudformation.lsp.intro.prompt.action.explore=Explore CloudFormation -cloudformation.lsp.intro.prompt.message=Author templates with hover, code completion, and cfn-lint support, validate and deploy templates to stacks, and view stack information such as events and resources -cloudformation.lsp.intro.prompt.title=Introducing the new CloudFormation Panel +cloudformation.lsp.intro.prompt.action.explore=Open CloudFormation Panel +cloudformation.lsp.intro.prompt.action.learn_more=Learn more +cloudformation.lsp.intro.prompt.message=Author templates with hover, code completion, and diagnostic support, validate and deploy templates to stacks, and view stack information such as events and resources +cloudformation.lsp.intro.prompt.title=Introducing CloudFormation Language Server cloudformation.lsp.stack.view=CloudFormation Stack cloudformation.missing_property=Property {0} not found in {1} cloudformation.settings.cfnguard.enable=Enable CloudFormation Guard validation diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt index 3b01643a299..99e0b2503d8 100644 --- a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.cfnlsp +import com.intellij.ide.BrowserUtil import com.intellij.notification.Notification import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationType @@ -12,6 +13,8 @@ import com.intellij.openapi.startup.ProjectActivity import software.aws.toolkits.jetbrains.core.explorer.AwsToolkitExplorerToolWindow import software.aws.toolkits.resources.AwsToolkitBundle.message +private const val LANGUAGE_SERVER_DOCS_URL = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/ide-extension.html" + internal class CfnLspIntroPrompter : ProjectActivity { override suspend fun execute(project: Project) { if (CfnLspIntroPromptState.getInstance().hasResponded()) return @@ -35,6 +38,12 @@ internal class CfnLspIntroPrompter : ProjectActivity { } }) + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.learn_more")) { + override fun actionPerformed(e: AnActionEvent, notification: Notification) { + BrowserUtil.browse(LANGUAGE_SERVER_DOCS_URL) + } + }) + notification.addAction(object : NotificationAction(message("cloudformation.lsp.intro.prompt.action.dont_show")) { override fun actionPerformed(e: AnActionEvent, notification: Notification) { applyChoice() From b7d2f7149cafd2b2e75449b75ca555d537cd0d90 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Wed, 18 Mar 2026 21:45:26 +0000 Subject: [PATCH 32/44] Updating version to 3.107 --- .changes/3.107.json | 5 +++++ CHANGELOG.md | 2 ++ gradle.properties | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changes/3.107.json diff --git a/.changes/3.107.json b/.changes/3.107.json new file mode 100644 index 00000000000..aa08efa4aa3 --- /dev/null +++ b/.changes/3.107.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-03-18", + "version" : "3.107", + "entries" : [ ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a53290edc..31e72e41fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +# _3.107_ (2026-03-18) + # _3.106_ (2026-03-13) - **(Feature)** Added support for validating and deploying CloudFormation templates to new or existing stacks - **(Feature)** Added intelligent authoring support for CloudFormation templates. diff --git a/gradle.properties b/gradle.properties index d53541233c6..c0e5573beb7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.107-SNAPSHOT +toolkitVersion=3.107 # Publish Settings publishToken= From 173d2ecad27120d01d0fef05dc3149e5f1c18d14 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 19 Mar 2026 20:59:32 +0000 Subject: [PATCH 33/44] Updating SNAPSHOT version to 3.108-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index c0e5573beb7..205afae2374 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.107 +toolkitVersion=3.108-SNAPSHOT # Publish Settings publishToken= From 96b6c737ef89ab14149c1d72d51f0f24618815ff Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:34:17 -0400 Subject: [PATCH 34/44] fix(cloudformation): update marketplace docs to highlight cfn lsp and remove Q and CodeWhisperer (#6312) --- .../resources/META-INF/plugin.xml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml index 59ba9bce660..cbc0774499f 100644 --- a/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml +++ b/plugins/toolkit/jetbrains-core/resources/META-INF/plugin.xml @@ -12,9 +12,6 @@ Amazon Q and CodeWhisperer -

    CodeWhisperer is now part of Amazon Q. Try the Amazon Q extension.

    -

    Amazon CodeCatalyst

    Unified software development service to quickly build and deliver applications on AWS.

    @@ -25,6 +22,17 @@

    +

    AWS CloudFormation Language Server

    + +
      +
    • + Author templates with documentation on hover, code completion, and diagnostics from cfn-lint and CloudFormation Guard +
    • +
    • + Validate and deploy stacks with confidence using the change set diff view of resource and property-level modifications, view stack details such as events and resources, and explore resources in the new CloudFormation tool window +
    • +
    +

    View, modify, and deploy AWS resources

      @@ -34,9 +42,6 @@
    • Resource Explorer - View and manage AWS resources
    • -
    • - CloudFormation Support - Author templates with hover, code completion, and cfn-lint support, validate and deploy templates to stacks, and view stack information such as events and resources -
    • Run/Debug Local Lambda Functions - Locally test and step-through debug functions in a Lambda-like execution environment provided by the AWS SAM CLI. Supports Java, Python, Node.js, and .NET.
    • From 1b68f77ec7d88ff5520ef106557d2e9870af0d3a Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:13:47 -0400 Subject: [PATCH 35/44] fix(cloudformation): change cloudformation language server path (#6310) * fix(cloudformation): change cloudformation language server path * cleanup legacy dir --- .../cfnlsp/stacks/views/OpenStackViewAction.kt | 2 +- .../services/cfnlsp/server/CfnLspInstaller.kt | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt index b9e9bfc8d41..41e6fac08b7 100644 --- a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt @@ -11,7 +11,7 @@ import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackNode -import software.aws.toolkits.resources.message +import software.aws.toolkits.resources.AwsToolkitBundle.message internal class OpenStackViewAction : AnAction(), DumbAware { diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt index db098b4f677..e161c8477cf 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt @@ -50,6 +50,8 @@ internal class CfnLspInstaller( val versionDir = storageDir.resolve(release.version) val serverPath = versionDir.resolve(CfnLspServerConfig.SERVER_FILE) + cleanupLegacyStorageDir() + return if (Files.exists(serverPath)) { LOG.info { "Using cached CloudFormation LSP ${release.version}" } serverPath @@ -144,6 +146,17 @@ internal class CfnLspInstaller( } } + private fun cleanupLegacyStorageDir() { + val legacyDir = getToolkitsCacheRoot().resolve("cloudformation-lsp") + if (!Files.exists(legacyDir)) return + try { + legacyDir.toFile().deleteRecursively() + LOG.info { "Removed legacy LSP directory: $legacyDir" } + } catch (e: Exception) { + LOG.warn(e) { "Failed to remove legacy LSP directory" } + } + } + /** * Removes old versions, keeping the current version and one compatible fallback. */ @@ -211,7 +224,7 @@ internal class CfnLspInstaller( private val LOG = getLogger() private const val MANIFEST_CACHE_KEY = "aws.cloudformation.lsp.manifest" - fun defaultStorageDir(): Path = getToolkitsCacheRoot().resolve("cloudformation-lsp") + fun defaultStorageDir(): Path = getToolkitsCacheRoot().resolve("language-servers").resolve("cloudformation-languageserver") internal fun parseHashString(hashString: String): Pair? { // Format: "sha256:abc123..." or "sha384:abc123..." From d9eb940a0f53b05720cd2843bc96e7d3c879ecf0 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:27:45 -0400 Subject: [PATCH 36/44] fix(cloudformation): fix platform detection for linux (#6315) --- .../aws/toolkits/jetbrains/core/lsp/LspUtils.kt | 7 ++++--- .../services/cfnlsp/server/GitHubManifestAdapter.kt | 11 ++--------- .../aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt | 5 ++--- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt index bb1dbe825b5..7d06dfa15b4 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/core/lsp/LspUtils.kt @@ -21,7 +21,8 @@ internal fun getCurrentOS(): String = when { } internal fun getCurrentArchitecture(): String = when (CpuArch.CURRENT) { - CpuArch.X86_64 -> "x64" - CpuArch.ARM64 -> "arm64" - else -> "unknown" + CpuArch.ARM32, + CpuArch.ARM64, + -> "arm64" + else -> "x64" } diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt index 9458cfc78ee..584e7006675 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/GitHubManifestAdapter.kt @@ -70,7 +70,7 @@ internal class GitHubManifestAdapter( var versions: List = mapper.readValue(root.get(envKey).toString()) if (SystemInfo.isLinux && legacyLinuxDetector.useLegacyLinux()) { - LOG.info { "Legacy Linux environment detected, remapping to linuxglib2.28" } + LOG.info { "Legacy Linux environment detected, using $LEGACY_LINUX_PLATFORM builds" } versions = remapLegacyLinux(versions) } @@ -82,7 +82,7 @@ internal class GitHubManifestAdapter( val version = latestCompatibleVersion(versions) - val platform = getEffectivePlatform() + val platform = getCurrentOS() val arch = getCurrentArchitecture() val target = version.targets.firstOrNull { it.platform == platform && it.arch == arch } @@ -140,13 +140,6 @@ internal class GitHubManifestAdapter( fun getCachedManifest(): String? = cachedManifestJson - private fun getEffectivePlatform(): String { - if (SystemInfo.isLinux && legacyLinuxDetector.useLegacyLinux()) { - return LEGACY_LINUX_PLATFORM - } - return getCurrentOS() - } - companion object { private val LOG = getLogger() private const val LEGACY_LINUX_PLATFORM = "linuxglib2.28" diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt index 219967bacbb..0d861956e4a 100644 --- a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/LspUtilsTest.kt @@ -40,9 +40,8 @@ class LspUtilsTest { val arch = getCurrentArchitecture() when (CpuArch.CURRENT) { - CpuArch.X86_64 -> assertThat(arch).isEqualTo("x64") - CpuArch.ARM64 -> assertThat(arch).isEqualTo("arm64") - else -> assertThat(arch).isEqualTo("unknown") + CpuArch.ARM32, CpuArch.ARM64 -> assertThat(arch).isEqualTo("arm64") + else -> assertThat(arch).isEqualTo("x64") } } } From aec71d946c0a9977f3ee2bde3941f44433b4232e Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:37:29 -0400 Subject: [PATCH 37/44] =?UTF-8?q?fix(cloudformation):=20update=20Node.js?= =?UTF-8?q?=20resolution=20to=20include=20common=20path=E2=80=A6=20(#6321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cloudformation): update Node.js resolution to be more robust for all operating systems and improve node failure prompt * make throw foldable --- .../resources/MessagesBundle.properties | 1 + .../aws.toolkit.cloudformation.lsp.xml | 2 + .../services/cfnlsp/CfnCredentialsService.kt | 3 + .../services/cfnlsp/CfnLspStartupActivity.kt | 1 + .../server/CfnLspServerSupportProvider.kt | 58 ++++-- .../jetbrains/core/lsp/NodeRuntimeResolver.kt | 166 ++++++++++++++---- .../services/cfnlsp/CfnNodePromptState.kt | 39 ++++ .../core/lsp/NodeRuntimeResolverTest.kt | 104 +++++++++++ 8 files changed, 328 insertions(+), 46 deletions(-) create mode 100644 plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt create mode 100644 plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt 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 2eb8a7e943e..73c0165e275 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -573,6 +573,7 @@ cloudformation.explorer.tab.title=CloudFormation cloudformation.invalid_property=Property {0} has invalid value {1} cloudformation.key_not_found={0} not found on resource {1} cloudformation.lsp.action.configure_node=Configure Node.js +cloudformation.lsp.action.download_node=Download Node.js cloudformation.lsp.error.download_failed=Failed to download CloudFormation LSP. Check your network connection. cloudformation.lsp.error.extraction_failed=Failed to extract CloudFormation LSP. cloudformation.lsp.error.hash_mismatch=Downloaded file integrity check failed. The file may be corrupted. diff --git a/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml index b9466af0f00..72f5a01fadf 100644 --- a/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml +++ b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml @@ -5,6 +5,7 @@ + @@ -25,6 +26,7 @@ /> + { + val exeName = EXE_NAME.getValue(platform) + return buildList { + if (platform == Platform.MAC) { + add(Path.of("/opt/homebrew/bin/$exeName")) + add(Path.of("/usr/local/bin/$exeName")) + add(home.resolve(".asdf/shims/$exeName")) + } + if (platform == Platform.LINUX) { + add(Path.of("/usr/bin/$exeName")) + add(Path.of("/usr/local/bin/$exeName")) + add(Path.of("/snap/bin/$exeName")) + add(Path.of("/home/linuxbrew/.linuxbrew/bin/$exeName")) + add(home.resolve(".asdf/shims/$exeName")) + } + if (platform == Platform.WINDOWS) { + add(Path.of("C:/Program Files/nodejs/$exeName")) + add(Path.of("C:/ProgramData/chocolatey/bin/$exeName")) + add(home.resolve("scoop/apps/nodejs/current/$exeName")) + } + } +} + +@VisibleForTesting +internal fun buildGlobPatterns(platform: Platform, home: Path, env: (String) -> String?): List { + val exeName = EXE_NAME.getValue(platform) + val bin = BIN_DIR.getValue(platform) + + return buildList { + if (platform == Platform.MAC) { + add("/opt/homebrew/Cellar/node*/*/bin/$exeName") + add("/usr/local/Cellar/node*/*/bin/$exeName") + } + + // nvm + val nvmDir = env("NVM_DIR")?.let { Path.of(it) } ?: home.resolve(".nvm") + if (platform != Platform.WINDOWS) { + add("$nvmDir/versions/node/v*/bin/$exeName") + } else { + val nvmHome = env("NVM_HOME")?.let { Path.of(it) } + ?: env("APPDATA")?.let { Path.of(it, "nvm") } + nvmHome?.let { add("$it/v*/$exeName") } + } + + // fnm + val fnmBase = when (platform) { + Platform.MAC -> home.resolve("Library/Application Support/fnm") + Platform.LINUX -> (env("XDG_DATA_HOME")?.let { Path.of(it) } ?: home.resolve(".local/share")).resolve("fnm") + Platform.WINDOWS -> env("APPDATA")?.let { Path.of(it, "fnm") } + } + fnmBase?.let { add("$it/node-versions/v*/installation/${bin}$exeName") } + + // volta + val voltaHome = if (platform == Platform.WINDOWS) { + env("LOCALAPPDATA")?.let { Path.of(it, "Volta") } + } else { + home.resolve(".volta") + } + voltaHome?.let { add("$it/tools/image/node/*/${bin}$exeName") } + } +} + +/** + * Resolves a Node.js executable across system PATH, well-known install locations, + * and version managers (nvm, fnm, volta). GUI-launched IDEs don't inherit shell + * PATH modifications, so we search common locations directly. + */ internal object NodeRuntimeResolver { private val LOG = getLogger() + private val home: Path = Path.of(System.getProperty("user.home")) + + private val platform: Platform = when { + SystemInfo.isMac -> Platform.MAC + SystemInfo.isWindows -> Platform.WINDOWS + else -> Platform.LINUX + } - /** - * Locates a Node.js executable with version >= minVersion. - * Uses IntelliJ's PathEnvironmentVariableUtil to search PATH. - * - * @return Path to valid Node.js executable, or null if not found - */ - fun resolve(minVersion: Int = 18): Path? { - val exeName = if (SystemInfo.isWindows) "node.exe" else "node" + private val exeName = EXE_NAME.getValue(platform) + private val wellKnownPaths: List = buildWellKnownPaths(platform, home) + private val globPatterns: List by lazy { buildGlobPatterns(platform, home) { System.getenv(it) } } - return PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) + fun resolve(minVersion: Int = 18): Path? = + resolveFromPath(minVersion) ?: resolveFromWellKnownLocations(minVersion) + + private fun resolveFromPath(minVersion: Int): Path? = + PathEnvironmentVariableUtil.findAllExeFilesInPath(exeName) .asSequence() .map { it.toPath() } .filter { Files.isRegularFile(it) && Files.isExecutable(it) } - .firstNotNullOfOrNull { validateVersion(it, minVersion) } - } + .firstNotNullOfOrNull { it.takeIfVersionAtLeast(minVersion) } - private fun validateVersion(path: Path, minVersion: Int): Path? = try { - val output = ExecUtil.execAndGetOutput( - com.intellij.execution.configurations.GeneralCommandLine(path.toString(), "--version"), - 5000 - ) - - if (output.exitCode == 0) { - val version = output.stdout.trim() - val majorVersion = version.removePrefix("v").split(".")[0].toIntOrNull() - - if (majorVersion != null && majorVersion >= minVersion) { - LOG.debug { "Node $version found at: $path" } - path.toAbsolutePath() - } else { - LOG.debug { "Node version < $minVersion at: $path (version: $version)" } - null + private fun resolveFromWellKnownLocations(minVersion: Int): Path? { + val fromFixed = wellKnownPaths.asSequence() + .filter { Files.isRegularFile(it) && Files.isExecutable(it) } + + val fromGlobs = globPatterns.asSequence() + .flatMap { expandGlob(it) } + + return (fromFixed + fromGlobs) + .mapNotNull { path -> + val version = path.nodeVersion() + if (version != null && version >= minVersion) version to path.toAbsolutePath() else null } + .maxByOrNull { it.first } + ?.second + } + + private fun expandGlob(glob: String): Sequence { + val parent = Path.of(glob.substringBefore("*")).parent ?: return emptySequence() + if (!Files.isDirectory(parent)) return emptySequence() + + val matcher = FileSystems.getDefault().getPathMatcher("glob:$glob") + val depth = glob.removePrefix(parent.toString()).count { it == '/' || it == '\\' } + 1 + + return Files.walk(parent, depth).use { stream -> + stream + .filter { matcher.matches(it) && Files.isRegularFile(it) && Files.isExecutable(it) } + .toList() + }.asSequence() + } + + private fun Path.nodeVersion(): Int? = try { + val output = ExecUtil.execAndGetOutput(GeneralCommandLine(toString(), "--version"), 5000) + if (output.exitCode == 0) output.stdout.trim().removePrefix("v").split(".")[0].toIntOrNull() else null + } catch (e: Exception) { + LOG.debug(e) { "Failed to get version from node at: $this" } + null + } + + private fun Path.takeIfVersionAtLeast(minVersion: Int): Path? { + val version = nodeVersion() ?: return null + return if (version >= minVersion) { + LOG.debug { "Node v$version found at: $this" } + toAbsolutePath() } else { - LOG.debug { "Failed to get version from node at: $path" } + LOG.debug { "Node v$version < $minVersion at: $this" } null } - } catch (e: Exception) { - LOG.debug(e) { "Failed to check version for node at: $path" } - null } } diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt new file mode 100644 index 00000000000..bccb8da9078 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnNodePromptState.kt @@ -0,0 +1,39 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.cfnlsp + +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 + +private const val FIFTEEN_DAYS_MS = 15L * 24 * 60 * 60 * 1000 + +@Service +@State(name = "cfnNodePromptState", storages = [Storage("awsToolkit.xml", roamingType = RoamingType.DISABLED)]) +internal class CfnNodePromptState : PersistentStateComponent { + private var state = State() + + override fun getState(): State = state + override fun loadState(state: State) { this.state = state } + + fun shouldPrompt(): Boolean { + if (state.lastPromptTime == 0L) return true + return System.currentTimeMillis() - state.lastPromptTime >= FIFTEEN_DAYS_MS + } + + fun dismissTemporarily() { + state.lastPromptTime = System.currentTimeMillis() + } + + class State( + var lastPromptTime: Long = 0L, + ) + + companion object { + fun getInstance(): CfnNodePromptState = service() + } +} diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt new file mode 100644 index 00000000000..8f6f58fa408 --- /dev/null +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt @@ -0,0 +1,104 @@ +// Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.lsp + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.nio.file.FileSystems +import java.nio.file.Path + +class NodeRuntimeResolverTest { + private val home = Path.of("/mock/home") + private val noEnv: (String) -> String? = { null } + private val fs = FileSystems.getDefault() + + @Test + fun `macOS well-known paths are valid`() { + val paths = buildWellKnownPaths(Platform.MAC, home) + assertThat(paths).isNotEmpty + assertThat(paths.map { it.toString() }).allSatisfy { assertThat(it).doesNotContain("*") } + } + + @Test + fun `linux well-known paths are valid`() { + val paths = buildWellKnownPaths(Platform.LINUX, home) + assertThat(paths).isNotEmpty + assertThat(paths.map { it.toString() }).allSatisfy { assertThat(it).doesNotContain("*") } + } + + @Test + fun `windows well-known paths are valid`() { + val paths = buildWellKnownPaths(Platform.WINDOWS, home) + assertThat(paths).isNotEmpty + assertThat(paths.map { it.toString() }).allSatisfy { assertThat(it).doesNotContain("*") } + } + + @Test + fun `macOS glob patterns are valid PathMatcher globs`() { + assertValidGlobs(buildGlobPatterns(Platform.MAC, home, noEnv)) + } + + @Test + fun `linux glob patterns are valid PathMatcher globs`() { + assertValidGlobs(buildGlobPatterns(Platform.LINUX, home, noEnv)) + } + + @Test + fun `windows glob patterns are valid PathMatcher globs with env vars`() { + val env: (String) -> String? = { + when (it) { + "APPDATA" -> "C:/Users/test/AppData/Roaming" + "LOCALAPPDATA" -> "C:/Users/test/AppData/Local" + else -> null + } + } + assertValidGlobs(buildGlobPatterns(Platform.WINDOWS, home, env)) + } + + @Test + fun `windows glob patterns handle missing env vars gracefully`() { + val patterns = buildGlobPatterns(Platform.WINDOWS, home, noEnv) + // With no env vars, nvm-windows/fnm/volta patterns requiring env vars are skipped + patterns.forEach { glob -> + fs.getPathMatcher("glob:$glob") + Path.of(glob.substringBefore("*")) + } + } + + @Test + fun `nvm glob respects NVM_DIR env var`() { + val customDir = "custom/nvm" + val env: (String) -> String? = { if (it == "NVM_DIR") "/custom/nvm" else null } + val patterns = buildGlobPatterns(Platform.LINUX, home, env) + assertThat(patterns).anyMatch { it.contains(customDir) } + } + + @Test + fun `glob pattern prefixes are valid paths on all platforms`() { + val windowsEnv: (String) -> String? = { + when (it) { + "APPDATA" -> "C:/Users/test/AppData/Roaming" + "LOCALAPPDATA" -> "C:/Users/test/AppData/Local" + else -> null + } + } + + for (platform in Platform.entries) { + val env = if (platform == Platform.WINDOWS) windowsEnv else noEnv + val patterns = buildGlobPatterns(platform, home, env) + patterns.forEach { glob -> + Path.of(glob.substringBefore("*")) + } + } + } + + private fun assertValidGlobs(patterns: List) { + assertThat(patterns).isNotEmpty + patterns.forEach { glob -> + assertThat(glob).contains("*") + fs.getPathMatcher("glob:$glob") + Path.of(glob.substringBefore("*")) + } + } +} From 91b82ac03db997441a996c2027bb8394619f5f5c Mon Sep 17 00:00:00 2001 From: amzn-kvk Date: Thu, 26 Mar 2026 17:04:35 +0100 Subject: [PATCH 38/44] Update main owner to be aws-toolkits-team (#6325) Changing the owner team --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bddc4d68dfa..e868b69ebfb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ -* @aws/aws-ides-team +* @aws/aws-toolkits-team chat/ @aws/codewhisperer-team codemodernizer/ @aws/elastic-gumby From dc389170aadd542aac44cd1474d27b32610367a7 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:26:20 -0400 Subject: [PATCH 39/44] fix(cloudformation): update assertion to be seperator agnostic for windows (#6326) --- .../aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt index 8f6f58fa408..0c31d5c2a82 100644 --- a/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt +++ b/plugins/toolkit/jetbrains-core/tst/software/aws/toolkits/jetbrains/core/lsp/NodeRuntimeResolverTest.kt @@ -68,10 +68,9 @@ class NodeRuntimeResolverTest { @Test fun `nvm glob respects NVM_DIR env var`() { - val customDir = "custom/nvm" val env: (String) -> String? = { if (it == "NVM_DIR") "/custom/nvm" else null } val patterns = buildGlobPatterns(Platform.LINUX, home, env) - assertThat(patterns).anyMatch { it.contains(customDir) } + assertThat(patterns).anyMatch { "custom" in it && "nvm" in it && "versions" in it } } @Test From 9a4abd274879ea792a86cee3cc61601496c2f67b Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 26 Mar 2026 18:30:19 +0000 Subject: [PATCH 40/44] Updating version to 3.108 --- .changes/3.108.json | 5 +++++ CHANGELOG.md | 2 ++ gradle.properties | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changes/3.108.json diff --git a/.changes/3.108.json b/.changes/3.108.json new file mode 100644 index 00000000000..e66a2704d19 --- /dev/null +++ b/.changes/3.108.json @@ -0,0 +1,5 @@ +{ + "date" : "2026-03-26", + "version" : "3.108", + "entries" : [ ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e72e41fce..22c87b34bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +# _3.108_ (2026-03-26) + # _3.107_ (2026-03-18) # _3.106_ (2026-03-13) diff --git a/gradle.properties b/gradle.properties index 205afae2374..7f347b5ea32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.108-SNAPSHOT +toolkitVersion=3.108 # Publish Settings publishToken= From 9412e70177e5e5331e0a29b6ec957469b343176e Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Thu, 26 Mar 2026 20:31:44 +0000 Subject: [PATCH 41/44] Updating SNAPSHOT version to 3.109-SNAPSHOT --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 7f347b5ea32..95111fc33c9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.108 +toolkitVersion=3.109-SNAPSHOT # Publish Settings publishToken= From ee1010eeb13128164fc1bd58264b242a748bb9d7 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:52:39 -0400 Subject: [PATCH 42/44] feat(cloudformation): enable CloudFormation Language Server beta via environemnt variable (#6327) --- .../services/cfnlsp/server/CfnLspInstaller.kt | 2 +- .../services/cfnlsp/server/CfnLspServerConfig.kt | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt index e161c8477cf..9ca2f047c0a 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspInstaller.kt @@ -23,7 +23,7 @@ import kotlin.io.path.isDirectory internal class CfnLspInstaller( private val storageDir: Path = defaultStorageDir(), - private val manifestAdapter: GitHubManifestAdapter = GitHubManifestAdapter(CfnLspEnvironment.PROD), + private val manifestAdapter: GitHubManifestAdapter = GitHubManifestAdapter(CfnLspEnvironment.fromEnvironment()), ) { private val httpClient = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NORMAL) diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt index d47845e78de..cff71e77e74 100644 --- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt +++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerConfig.kt @@ -13,5 +13,14 @@ internal object CfnLspServerConfig { } internal enum class CfnLspEnvironment { - ALPHA, BETA, PROD + ALPHA, BETA, PROD; + + companion object { + fun fromEnvironment(): CfnLspEnvironment = + if (System.getenv("AWS_TOOLKIT_AUTOMATION")?.equals("true", ignoreCase = true) == true) { + BETA + } else { + PROD + } + } } From f624adefad2783e444c3487390017feef98829b2 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:09:24 -0400 Subject: [PATCH 43/44] fix(rider): move ModelZoneMarket.cs out of generated Protocol directory (#6328) ModelZoneMarket.cs is a hand-written zone marker that was placed in ReSharper.AWS/src/AWS.Psi/Protocol/, which is declared as a Gradle task output directory for the generateModels RdGen task. During buildPlugin, Gradle cleans stale outputs from this directory, deleting ModelZoneMarket.cs since it is not produced by the code generator. Move the file up one level to ReSharper.AWS/src/AWS.Psi/ where other hand-written files like ZoneMarker.cs already reside. --- .../ReSharper.AWS/src/AWS.Psi/{Protocol => }/ModelZoneMarket.cs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/{Protocol => }/ModelZoneMarket.cs (100%) diff --git a/plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Protocol/ModelZoneMarket.cs b/plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/ModelZoneMarket.cs similarity index 100% rename from plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/Protocol/ModelZoneMarket.cs rename to plugins/toolkit/jetbrains-rider/ReSharper.AWS/src/AWS.Psi/ModelZoneMarket.cs From 70e513c1c0f3e3d5acc357f12d5fc65f8fdbfb04 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmed <37942674+Zee2413@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:28:12 -0400 Subject: [PATCH 44/44] fix(cloudformation): migrate to ProjectActivity (#6329) --- .../jetbrains/services/cfnlsp/CfnLspStartupActivity.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt index 959ffb2a533..2320e042b87 100644 --- a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt +++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt @@ -4,13 +4,13 @@ package software.aws.toolkits.jetbrains.services.cfnlsp import com.intellij.openapi.project.Project -import com.intellij.openapi.startup.StartupActivity +import com.intellij.openapi.startup.ProjectActivity import com.intellij.platform.lsp.api.LspServerManager import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerDescriptor import software.aws.toolkits.jetbrains.services.cfnlsp.server.CfnLspServerSupportProvider -internal class CfnLspStartupActivity : StartupActivity { - override fun runActivity(project: Project) { +internal class CfnLspStartupActivity : ProjectActivity { + override suspend fun execute(project: Project) { CfnCredentialsService.getInstance(project) // eagerly initialize to register settings change listener LspServerManager.getInstance(project).ensureServerStarted( CfnLspServerSupportProvider::class.java,