-
- 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 @@
-
-
-
-
-
+ 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
@@ -106,6 +114,7 @@
com.intellij.modules.javacom.intellij.modules.pythoncom.jetbrains.gateway
+ com.intellij.modules.lsp
@@ -172,6 +181,7 @@
+
@@ -180,6 +190,7 @@
+
@@ -263,6 +274,7 @@
instance="software.aws.toolkits.jetbrains.settings.DynamicResourcesConfigurable"
/>
+
@@ -277,6 +289,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..79319038a80
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnCredentialsService.kt
@@ -0,0 +1,194 @@
+// 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.server.CfnLspServerSupportProvider
+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)
+ }
+ }
+ }
+ )
+ }
+
+ @Suppress("UnstableApiUsage")
+ private fun subscribeToSettingsChanges(appBus: com.intellij.util.messages.MessageBusConnection) {
+ appBus.subscribe(
+ CfnLspSettingsChangeListener.TOPIC,
+ CfnLspSettingsChangeListener {
+ notifyConfigurationChanged()
+ LspServerManager.getInstance(project).stopAndRestartIfNeeded(CfnLspServerSupportProvider::class.java)
+ }
+ )
+ }
+
+ @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..99e0b2503d8
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspIntroPrompter.kt
@@ -0,0 +1,68 @@
+// 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.project.Project
+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
+
+ 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.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()
+ 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..2320e042b87
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspStartupActivity.kt
@@ -0,0 +1,20 @@
+// 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.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 : ProjectActivity {
+ override suspend fun execute(project: Project) {
+ CfnCredentialsService.getInstance(project) // eagerly initialize to register settings change listener
+ 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..0253e17490a
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/server/CfnLspServerSupportProvider.kt
@@ -0,0 +1,262 @@
+// 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.Notification
+import com.intellij.notification.NotificationAction
+import com.intellij.notification.NotificationType
+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.services.cfnlsp.CfnNodePromptState
+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.Files
+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: Exception) {
+ LOG.warn(e) { "Failed to resolve Node.js runtime" }
+ notifyNodeError()
+ throw (e as? CfnLspException) ?: CfnLspException(
+ message("cloudformation.lsp.error.node_not_found"),
+ CfnLspException.ErrorCode.NODE_NOT_FOUND,
+ 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()) {
+ val configured = Path.of(settings.nodeRuntimePath)
+ if (Files.isExecutable(configured)) return configured
+ LOG.warn { "Configured Node.js path is not executable: $configured, falling back to auto-detection" }
+ }
+
+ 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() {
+ val promptState = CfnNodePromptState.getInstance()
+ if (!promptState.shouldPrompt()) return
+
+ var actionTaken = false
+
+ val notification = Notification(
+ "aws.cfn.node",
+ message("cloudformation.lsp.error.title"),
+ message("cloudformation.lsp.error.node_not_found"),
+ NotificationType.WARNING
+ )
+
+ notification.addAction(
+ NotificationAction.createSimple(message("cloudformation.lsp.action.download_node")) {
+ BrowserUtil.browse("https://nodejs.org/en/download")
+ actionTaken = true
+ }
+ )
+
+ notification.addAction(
+ NotificationAction.createSimple(message("cloudformation.lsp.action.configure_node")) {
+ ShowSettingsUtil.getInstance().showSettingsDialog(project, "aws.cloudformation")
+ actionTaken = true
+ }
+ )
+
+ notification.whenExpired {
+ if (!actionTaken) promptState.dismissTemporarily()
+ }
+
+ notification.notify(project)
+ }
+
+ 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..41e6fac08b7
--- /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.AwsToolkitBundle.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