diff --git a/.changes/next-release/feature-3b7e99fd-3c79-4dec-a5f9-dcf1799062ea.json b/.changes/next-release/feature-3b7e99fd-3c79-4dec-a5f9-dcf1799062ea.json
new file mode 100644
index 00000000000..99d29cd82b7
--- /dev/null
+++ b/.changes/next-release/feature-3b7e99fd-3c79-4dec-a5f9-dcf1799062ea.json
@@ -0,0 +1,4 @@
+{
+ "type" : "feature",
+ "description" : "Add the stack view panel. Users can right click on a stack name to bring up the panel. The overview panel shows stack name, status, stack ID, description, create time, update time, and status reason."
+}
\ No newline at end of file
diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
index 92eab905110..f98015c020a 100644
--- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
+++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
@@ -572,6 +572,7 @@ cloudformation.lsp.error.manifest_failed=Failed to fetch CloudFormation LSP mani
cloudformation.lsp.error.no_compatible_version=No compatible CloudFormation LSP version found for your platform.
cloudformation.lsp.error.node_not_found=Node.js 18+ not found. Install Node.js or configure the path in Settings.
cloudformation.lsp.error.title=CloudFormation Language Server
+cloudformation.lsp.stack.view=CloudFormation Stack
cloudformation.missing_property=Property {0} not found in {1}
cloudformation.settings.cfnguard.enable=Enable CloudFormation Guard validation
cloudformation.settings.cfnguard.enabledRulePacks=Enabled rule packs:
diff --git a/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml
index a830ee9fe80..2190738295c 100644
--- a/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml
+++ b/plugins/toolkit/jetbrains-core/resources-253+/META-INF/aws.toolkit.cloudformation.lsp.xml
@@ -5,6 +5,10 @@
+
+
@@ -63,6 +67,10 @@
class="software.aws.toolkits.jetbrains.services.cfnlsp.explorer.actions.GetStackManagementInfoAction"
text="Get Stack Management Info"/>
+
+
+
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
index 4c52429a869..c371ebbe852 100644
--- 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
@@ -16,6 +16,8 @@ import org.eclipse.lsp4j.DidOpenTextDocumentParams
import org.eclipse.lsp4j.TextDocumentItem
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.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.GetStackActionStatusResult
import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable
@@ -101,6 +103,8 @@ internal class CfnClientService(project: Project) {
)
}
}
+ fun describeStack(params: DescribeStackParams): CompletableFuture =
+ sendRequest { it.describeStack(params) }
fun notifyConfigurationChanged() {
lspServerProvider()?.sendNotification { lsp ->
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
index cf2f93bc1c7..83857fc0e1e 100644
--- 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
@@ -257,7 +257,7 @@ class LoadMoreResourcesAction : AnAction(
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)
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..543e897b18e
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/OpenStackViewAction.kt
@@ -0,0 +1,52 @@
+// 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.toolkit.core.utils.getLogger
+import software.aws.toolkits.jetbrains.core.explorer.ExplorerTreeToolWindowDataKeys
+import software.aws.toolkits.jetbrains.services.cfnlsp.explorer.nodes.StackNode
+import software.aws.toolkits.resources.message
+
+internal class OpenStackViewAction : AnAction(), DumbAware {
+
+ override fun getActionUpdateThread() = ActionUpdateThread.BGT
+
+ override fun update(e: AnActionEvent) {
+ e.presentation.text = message("cloudformation.stack.view")
+ val stackNode = getStackNode(e)
+ e.presentation.isEnabledAndVisible = stackNode != null
+ }
+
+ override fun actionPerformed(e: AnActionEvent) {
+ val project = e.project ?: return
+ val stackNode = getStackNode(e) ?: return
+
+ if (stackNode.stack.stackName == null) {
+ LOG.error("Stack name is null for stack node")
+ return
+ }
+ if (stackNode.stack.stackId == null) {
+ LOG.error("Stack ID is null for stack node")
+ return
+ }
+ val stackName = stackNode.stack.stackName
+ val stackId = stackNode.stack.stackId
+
+ StackViewWindowManager.getInstance(project)
+ .openStack(stackName, stackId)
+ }
+
+ private fun getStackNode(e: AnActionEvent): StackNode? {
+ val selectedNodes = e.getData(ExplorerTreeToolWindowDataKeys.SELECTED_NODES)
+ return selectedNodes?.singleOrNull() as? StackNode
+ }
+
+ companion object {
+ private val LOG = getLogger()
+ }
+}
diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/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..ccd4999fb35
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanel.kt
@@ -0,0 +1,223 @@
+// 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.ide.BrowserUtil
+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.toolkit.core.utils.getLogger
+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.Cursor
+import java.awt.FlowLayout
+import java.awt.Font
+import java.awt.GridBagConstraints
+import java.awt.event.MouseAdapter
+import java.awt.event.MouseEvent
+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, StackPanelListener {
+
+ private val cfnClientService = CfnClientService.getInstance(project)
+ private val disposables = mutableListOf()
+
+ internal val consoleLink = JBLabel(IconUtils.createBlueIcon(AllIcons.Ide.External_link_arrow)).apply {
+ cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
+ isVisible = false
+ addMouseListener(object : MouseAdapter() {
+ override fun mouseClicked(e: MouseEvent) {
+ currentStackId?.let { stackId ->
+ val consoleUrl = ConsoleUrlGenerator.generateUrl(stackId)
+ BrowserUtil.browse(consoleUrl)
+ }
+ }
+ })
+ }
+
+ 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.addListener(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 onStackUpdated() {
+ 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(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 ICON_SPACING = 8
+ 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..22c5850c026
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilder.kt
@@ -0,0 +1,69 @@
+// 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.util.ui.JBUI
+import com.intellij.util.ui.UIUtil
+import java.awt.Font
+import java.awt.GridBagConstraints
+import java.awt.GridBagLayout
+import javax.swing.JComponent
+import javax.swing.JPanel
+
+internal object StackPanelLayoutBuilder {
+
+ 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)
+ }
+}
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..f54b81dc8a8
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackStatusPoller.kt
@@ -0,0 +1,87 @@
+// 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.toolkit.core.utils.getLogger
+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..2f4deab724b
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinator.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.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.toolkit.core.utils.getLogger
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+
+internal interface StackPanelListener {
+ fun onStackUpdated()
+}
+
+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 listeners = 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)
+ }
+ } ?: LOG.warn("Stack not found for status update: $stackArn")
+ }
+
+ fun getStackState(stackArn: String): StackState? = stackStates[stackArn]
+
+ fun removeStack(stackArn: String) {
+ stackStates.remove(stackArn)
+ listeners.remove(stackArn)
+ }
+
+ fun addListener(stackArn: String, listener: StackPanelListener): Disposable {
+ listeners.computeIfAbsent(stackArn) { CopyOnWriteArrayList() }.add(listener)
+
+ // Immediately notify new listener of current state
+ stackStates[stackArn]?.let {
+ listener.onStackUpdated()
+ }
+
+ return Disposable {
+ listeners[stackArn]?.remove(listener)
+ if (listeners[stackArn]?.isEmpty() == true) {
+ listeners.remove(stackArn)
+ }
+ }
+ }
+
+ private fun notifyListeners(stackArn: String) {
+ listeners[stackArn]?.forEach {
+ it.onStackUpdated()
+ }
+ }
+
+ override fun dispose() {
+ stackStates.clear()
+ listeners.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..7c1234f753f
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewPanelTabber.kt
@@ -0,0 +1,82 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.project.Project
+import com.intellij.ui.components.JBLabel
+import com.intellij.ui.components.JBPanel
+import com.intellij.ui.components.JBTabbedPane
+import java.awt.BorderLayout
+import javax.swing.JComponent
+import javax.swing.JPanel
+
+internal class StackViewPanelTabber(
+ project: Project,
+ private val stackName: String,
+ private val stackArn: String, // Use ARN as primary identifier
+) : 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 tabbedPane = JBTabbedPane().apply {
+ addTab("Overview", createOverviewPanel())
+ addTab("Resources", createResourcesPanel())
+ addTab("Events", createEventsPanel())
+ addTab("Outputs", createOutputsPanel())
+ selectedIndex = 0
+ }
+
+ // Used for future change set functionality
+ fun addTab(title: String, component: JComponent, index: Int? = null) {
+ if (index != null) {
+ tabbedPane.insertTab(title, null, component, null, index)
+ } else {
+ tabbedPane.addTab(title, component)
+ }
+ }
+
+ // Used for future change set functionality
+ fun removeTab(title: String) {
+ for (i in 0 until tabbedPane.tabCount) {
+ if (tabbedPane.getTitleAt(i) == title) {
+ tabbedPane.removeTabAt(i)
+ break
+ }
+ }
+ }
+
+ private val mainPanel = JBPanel>(BorderLayout()).apply {
+ add(tabbedPane, BorderLayout.CENTER)
+ }
+
+ private fun createOverviewPanel(): JComponent = overviewPanel.component
+
+ private fun createResourcesPanel(): JPanel = JBPanel>().apply {
+ add(JBLabel("Stack Resources - Coming Soon"))
+ }
+
+ private fun createEventsPanel(): JPanel = JBPanel>().apply {
+ add(JBLabel("Stack Events - Coming Soon"))
+ }
+
+ private fun createOutputsPanel(): JPanel = JBPanel>().apply {
+ add(JBLabel("Stack Outputs - Coming Soon"))
+ }
+
+ fun start() {
+ coordinator.setStack(stackArn, stackName)
+ poller.setViewVisible(true)
+ }
+
+ fun getComponent(): JPanel = mainPanel
+
+ override fun dispose() {
+ poller.dispose()
+ overviewPanel.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..69e5f963095
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewWindowManager.kt
@@ -0,0 +1,156 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views
+
+import com.intellij.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.toolkit.core.utils.getLogger
+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 openStack(stackName: String, stackId: String) {
+ 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("Failed to create StackDetailView", e)
+ 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.addListener(
+ stackId,
+ object : StackPanelListener {
+ override fun onStackUpdated() {
+ 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..89e4fab2b7f
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/Utils.kt
@@ -0,0 +1,38 @@
+// 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.toolkit.core.utils.getLogger
+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("Failed to parse date string: $dateString", e)
+ null
+ }
+}
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..024fb2658a6
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/Utils.kt
@@ -0,0 +1,34 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.ui
+
+import com.intellij.ui.JBColor
+import com.intellij.util.ui.UIUtil
+import java.awt.AlphaComposite
+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")}"
+}
+
+internal object IconUtils {
+ fun createBlueIcon(originalIcon: Icon): Icon {
+ val size = 16
+ val image = UIUtil.createImage(size, size, BufferedImage.TYPE_INT_ARGB)
+ val g2d = image.createGraphics()
+
+ originalIcon.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)
+ }
+}
diff --git a/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt
new file mode 100644
index 00000000000..c0c840d5b2d
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/src-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/WrappingTextArea.kt
@@ -0,0 +1,21 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.ui
+
+import com.intellij.ui.components.JBTextArea
+import java.awt.Dimension
+import java.awt.Font
+
+internal class WrappingTextArea(text: String) : JBTextArea(text) {
+ init {
+ isEditable = false
+ isOpaque = false
+ lineWrap = true
+ wrapStyleWord = true
+ font = font.deriveFont(Font.PLAIN)
+ border = null
+ }
+
+ override fun getMinimumSize(): Dimension = Dimension(50, 20)
+}
diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt
index 00ff6772863..c97acb918af 100644
--- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt
+++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/CfnLspServerProtocol.kt
@@ -7,6 +7,8 @@ import org.eclipse.lsp4j.jsonrpc.services.JsonRequest
import org.eclipse.lsp4j.services.LanguageServer
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.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.GetStackActionStatusResult
import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.Identifiable
@@ -42,8 +44,6 @@ internal interface CfnLspServerProtocol : LanguageServer {
@JsonRequest("aws/cfn/stack/changeSet/list")
fun listChangeSets(params: ListChangeSetsParams): CompletableFuture
- // Resources: aws/cfn/resources
-
@JsonRequest("aws/cfn/resources/types")
fun listResourceTypes(): CompletableFuture
@@ -74,4 +74,8 @@ internal interface CfnLspServerProtocol : LanguageServer {
@JsonRequest("aws/cfn/stack/validation/status/describe")
fun describeValidationStatus(params: Identifiable): CompletableFuture
+
+ // Stack View
+ @JsonRequest("aws/cfn/stack/describe")
+ fun describeStack(params: DescribeStackParams): CompletableFuture
}
diff --git a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt
index 61e31971501..f54e173d9e2 100644
--- a/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt
+++ b/plugins/toolkit/jetbrains-core/src/software/aws/toolkits/jetbrains/services/cfnlsp/protocol/StackProtocol.kt
@@ -40,3 +40,39 @@ internal data class ListChangeSetsResult(
val changeSets: List,
val nextToken: String? = null,
)
+
+internal data class DescribeStackParams(
+ val stackName: String,
+)
+
+internal data class DescribeStackResult(
+ val stack: StackDetail?,
+)
+
+internal data class StackDetail(
+ @SerializedName("StackName")
+ val stackName: String,
+ @SerializedName("StackId")
+ val stackId: String,
+ @SerializedName("StackStatus")
+ val stackStatus: String,
+ @SerializedName("StackStatusReason")
+ val stackStatusReason: String? = null,
+ @SerializedName("CreationTime")
+ val creationTime: String? = null,
+ @SerializedName("LastUpdatedTime")
+ val lastUpdatedTime: String? = null,
+ @SerializedName("Description")
+ val description: String? = null,
+ @SerializedName("Outputs")
+ val outputs: List = emptyList(),
+)
+
+internal data class StackOutput(
+ @SerializedName("OutputKey")
+ val outputKey: String,
+ @SerializedName("OutputValue")
+ val outputValue: String,
+ @SerializedName("Description")
+ val description: String? = null,
+)
diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt
index ce063723010..1eea49dd3ca 100644
--- a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt
+++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/resources/ResourceLoaderTest.kt
@@ -4,7 +4,6 @@
package software.aws.toolkits.jetbrains.services.cfnlsp.resources
import com.intellij.testFramework.ProjectRule
-
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt
new file mode 100644
index 00000000000..3ef02a54bcc
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackOverviewPanelTest.kt
@@ -0,0 +1,159 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views
+
+import com.intellij.testFramework.PlatformTestUtil
+import com.intellij.testFramework.ProjectRule
+import com.intellij.testFramework.runInEdtAndWait
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import software.aws.toolkits.jetbrains.services.cfnlsp.CfnClientService
+import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.DescribeStackResult
+import software.aws.toolkits.jetbrains.services.cfnlsp.protocol.StackDetail
+import java.util.concurrent.CompletableFuture
+
+class StackOverviewPanelTest {
+
+ @get:Rule
+ val projectRule = ProjectRule()
+
+ private val testStackArn = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-test-stack/12345"
+ private lateinit var mockCfnClient: CfnClientService
+ private lateinit var mockCoordinator: StackViewCoordinator
+
+ @Before
+ fun setUp() {
+ mockCfnClient = mockk()
+ mockCoordinator = mockk()
+ mockkObject(CfnClientService)
+ every { CfnClientService.getInstance(projectRule.project) } returns mockCfnClient
+ every { mockCoordinator.addListener(any(), any()) } returns mockk()
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(CfnClientService)
+ }
+
+ @Test
+ fun `renderStack updates all field values correctly`() {
+ val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "my-test-stack")
+
+ val testStack = StackDetail(
+ stackName = "my-test-stack",
+ stackId = testStackArn,
+ stackStatus = "CREATE_COMPLETE",
+ description = "Test stack description",
+ creationTime = "2024-01-15T10:30:45Z",
+ lastUpdatedTime = "2024-01-15T11:00:00Z",
+ stackStatusReason = "Stack creation completed successfully"
+ )
+
+ panel.renderStack(testStack)
+
+ assertThat(panel.stackNameValue.text).isEqualTo("my-test-stack")
+ assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE")
+ assertThat(panel.stackIdValue.text).isEqualTo(testStackArn)
+ assertThat(panel.descriptionValue.text).isEqualTo("Test stack description")
+ assertThat(panel.consoleLink.isVisible).isTrue()
+ }
+
+ @Test
+ fun `renderStack with empty stack ID hides console link`() {
+ val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "test-stack")
+
+ val testStack = StackDetail(
+ stackName = "test-stack",
+ stackId = "",
+ stackStatus = "CREATE_COMPLETE",
+ description = null,
+ creationTime = null,
+ lastUpdatedTime = null,
+ stackStatusReason = null
+ )
+
+ panel.renderStack(testStack)
+
+ assertThat(panel.consoleLink.isVisible).isFalse()
+ }
+
+ @Test
+ fun `renderStack with null optional fields handles gracefully`() {
+ val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "minimal-stack")
+
+ val testStack = StackDetail(
+ stackName = "minimal-stack",
+ stackId = testStackArn,
+ stackStatus = "CREATE_COMPLETE",
+ description = null,
+ creationTime = null,
+ lastUpdatedTime = null,
+ stackStatusReason = null
+ )
+
+ panel.renderStack(testStack)
+
+ assertThat(panel.stackNameValue.text).isEqualTo("minimal-stack")
+ assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE")
+ }
+
+ @Test
+ fun `renderStack formats dates correctly`() {
+ val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "date-test-stack")
+
+ val testStack = StackDetail(
+ stackName = "date-test-stack",
+ stackId = testStackArn,
+ stackStatus = "CREATE_COMPLETE",
+ description = null,
+ creationTime = "2024-01-15T10:30:45Z",
+ lastUpdatedTime = "2024-01-15T11:00:00Z",
+ stackStatusReason = null
+ )
+
+ panel.renderStack(testStack)
+
+ assertThat(panel.createdValue.text).contains("15/1/2024")
+ assertThat(panel.lastUpdatedValue.text).contains("15/1/2024")
+ }
+
+ @Test
+ fun `onStackUpdated triggers stack reload`() {
+ val panel = StackOverviewPanel(projectRule.project, mockCoordinator, testStackArn, "my-stack")
+
+ // Create a future we can control
+ val futureResult = CompletableFuture()
+ every { mockCfnClient.describeStack(any()) } returns futureResult
+
+ // Should trigger reload
+ panel.onStackUpdated()
+
+ // Complete the future synchronously
+ val mockStack = StackDetail(
+ stackName = "my-stack",
+ stackId = testStackArn,
+ stackStatus = "CREATE_COMPLETE",
+ description = null,
+ creationTime = null,
+ lastUpdatedTime = null,
+ stackStatusReason = null
+ )
+ futureResult.complete(DescribeStackResult(mockStack))
+
+ // Process EDT events to execute the invokeLater block from loadStackDetails
+ runInEdtAndWait {
+ PlatformTestUtil.dispatchAllEventsInIdeEventQueue()
+ }
+
+ assertThat(panel.stackNameValue.text).isEqualTo("my-stack")
+ assertThat(panel.statusValue.text).isEqualTo("CREATE_COMPLETE")
+ }
+}
diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt
new file mode 100644
index 00000000000..23b055749ef
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackPanelLayoutBuilderTest.kt
@@ -0,0 +1,141 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views
+
+import com.intellij.ui.components.JBLabel
+import com.intellij.util.ui.UIUtil
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import java.awt.GridBagConstraints
+import java.awt.GridBagLayout
+import javax.swing.JPanel
+
+class StackPanelLayoutBuilderTest {
+
+ @Test
+ fun `createTitleLabel creates label with correct styling`() {
+ val label = StackPanelLayoutBuilder.createTitleLabel("Test Label")
+
+ assertThat(label.text).isEqualTo("Test Label")
+ assertThat(label.foreground).isEqualTo(UIUtil.getContextHelpForeground())
+ assertThat(label.font.isBold).isTrue()
+ }
+
+ @Test
+ fun `createFormPanel creates panel with GridBagLayout and default padding`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel()
+
+ assertThat(panel.layout).isInstanceOf(GridBagLayout::class.java)
+ assertThat(panel.border).isNotNull()
+ }
+
+ @Test
+ fun `createFormPanel creates panel with custom padding`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel(30)
+
+ assertThat(panel.layout).isInstanceOf(GridBagLayout::class.java)
+ assertThat(panel.border).isNotNull()
+ }
+
+ @Test
+ fun `addLabeledField adds label and component to panel`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel()
+ val gbc = GridBagConstraints().apply {
+ anchor = GridBagConstraints.NORTHWEST
+ fill = GridBagConstraints.HORIZONTAL
+ weightx = 1.0
+ }
+ val testComponent = JBLabel("Test Component")
+
+ val nextRow = StackPanelLayoutBuilder.addLabeledField(
+ panel,
+ gbc,
+ 0,
+ "Test Field",
+ testComponent
+ )
+
+ assertThat(nextRow).isEqualTo(2)
+ assertThat(panel.componentCount).isEqualTo(2) // Label + component
+
+ // Verify first component is the title label
+ val titleLabel = panel.getComponent(0) as JBLabel
+ assertThat(titleLabel.text).isEqualTo("Test Field")
+ assertThat(titleLabel.font.isBold).isTrue()
+
+ // Verify second component is our test component
+ assertThat(panel.getComponent(1)).isEqualTo(testComponent)
+ }
+
+ @Test
+ fun `addLabeledField with fillNone modifies constraints correctly`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel()
+ val gbc = GridBagConstraints().apply {
+ anchor = GridBagConstraints.NORTHWEST
+ fill = GridBagConstraints.HORIZONTAL
+ weightx = 1.0
+ }
+ val testComponent = JBLabel("Test Component")
+
+ StackPanelLayoutBuilder.addLabeledField(
+ panel,
+ gbc,
+ 0,
+ "Test Field",
+ testComponent,
+ fillNone = true
+ )
+
+ // Constraints should be reset after the method
+ assertThat(gbc.fill).isEqualTo(GridBagConstraints.HORIZONTAL)
+ assertThat(gbc.anchor).isEqualTo(GridBagConstraints.NORTHWEST)
+ }
+
+ @Test
+ fun `addLabeledField with isLast uses different insets`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel()
+ val gbc = GridBagConstraints()
+ val testComponent = JBLabel("Test Component")
+
+ val nextRow = StackPanelLayoutBuilder.addLabeledField(
+ panel,
+ gbc,
+ 0,
+ "Test Field",
+ testComponent,
+ isLast = true
+ )
+
+ assertThat(nextRow).isEqualTo(2)
+ assertThat(panel.componentCount).isEqualTo(2)
+ }
+
+ @Test
+ fun `addFiller adds empty panel with correct constraints`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel()
+ val gbc = GridBagConstraints()
+
+ StackPanelLayoutBuilder.addFiller(panel, gbc, 2)
+
+ assertThat(panel.componentCount).isEqualTo(1)
+ assertThat(panel.getComponent(0)).isInstanceOf(JPanel::class.java)
+ assertThat(gbc.gridy).isEqualTo(4) // row + 2
+ assertThat(gbc.weighty).isEqualTo(1.0)
+ assertThat(gbc.fill).isEqualTo(GridBagConstraints.BOTH)
+ }
+
+ @Test
+ fun `multiple addLabeledField calls increment rows correctly`() {
+ val panel = StackPanelLayoutBuilder.createFormPanel()
+ val gbc = GridBagConstraints()
+
+ var row = 0
+ row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 1", JBLabel("Value 1"))
+ row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 2", JBLabel("Value 2"))
+ row = StackPanelLayoutBuilder.addLabeledField(panel, gbc, row, "Field 3", JBLabel("Value 3"))
+
+ assertThat(row).isEqualTo(6) // 3 fields * 2 rows each
+ assertThat(panel.componentCount).isEqualTo(6) // 3 labels + 3 components
+ }
+}
diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt
new file mode 100644
index 00000000000..0b0d663fdbf
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/StackViewCoordinatorTest.kt
@@ -0,0 +1,201 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class StackViewCoordinatorTest {
+
+ private lateinit var coordinator: StackViewCoordinator
+ private val testStackArn1 = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack-1/12345"
+ private val testStackArn2 = "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack-2/67890"
+
+ @BeforeEach
+ fun setUp() {
+ coordinator = StackViewCoordinator()
+ }
+
+ @Test
+ fun `setStack updates state and notifies listeners for specific stack`() {
+ var notificationCount = 0
+
+ val listener = object : StackPanelListener {
+ override fun onStackUpdated() {
+ notificationCount++
+ }
+ }
+
+ coordinator.addListener(testStackArn1, listener)
+ coordinator.setStack(testStackArn1, "test-stack-1")
+
+ assertThat(notificationCount).isEqualTo(1)
+
+ val state = coordinator.getStackState(testStackArn1)
+ assertThat(state?.stackName).isEqualTo("test-stack-1")
+ assertThat(state?.stackArn).isEqualTo(testStackArn1)
+ }
+
+ @Test
+ fun `updateStackStatus only notifies listeners for specific stack`() {
+ var stack1Updates = 0
+ var stack2Updates = 0
+
+ val listener1 = object : StackPanelListener {
+ override fun onStackUpdated() {
+ stack1Updates++
+ }
+ }
+
+ val listener2 = object : StackPanelListener {
+ override fun onStackUpdated() {
+ stack2Updates++
+ }
+ }
+
+ coordinator.addListener(testStackArn1, listener1)
+ coordinator.addListener(testStackArn2, listener2)
+ coordinator.setStack(testStackArn1, "stack-1")
+ coordinator.setStack(testStackArn2, "stack-2")
+
+ // Reset counters after initial setStack calls
+ stack1Updates = 0
+ stack2Updates = 0
+
+ // Update stack 1 status
+ coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS")
+ assertThat(stack1Updates).isEqualTo(1)
+ assertThat(stack2Updates).isEqualTo(0)
+
+ // Update stack 2 status
+ coordinator.updateStackStatus(testStackArn2, "UPDATE_COMPLETE")
+ assertThat(stack1Updates).isEqualTo(1)
+ assertThat(stack2Updates).isEqualTo(1)
+
+ // Same status should not notify
+ coordinator.updateStackStatus(testStackArn1, "CREATE_IN_PROGRESS")
+ assertThat(stack1Updates).isEqualTo(1)
+ }
+
+ @Test
+ fun `multiple stacks can be managed independently`() {
+ coordinator.setStack(testStackArn1, "stack-1")
+ coordinator.setStack(testStackArn2, "stack-2")
+
+ val state1 = coordinator.getStackState(testStackArn1)
+ val state2 = coordinator.getStackState(testStackArn2)
+
+ assertThat(state1?.stackName).isEqualTo("stack-1")
+ assertThat(state2?.stackName).isEqualTo("stack-2")
+
+ coordinator.updateStackStatus(testStackArn1, "CREATE_COMPLETE")
+ coordinator.updateStackStatus(testStackArn2, "UPDATE_IN_PROGRESS")
+
+ val updatedState1 = coordinator.getStackState(testStackArn1)
+ val updatedState2 = coordinator.getStackState(testStackArn2)
+
+ assertThat(updatedState1?.status).isEqualTo("CREATE_COMPLETE")
+ assertThat(updatedState2?.status).isEqualTo("UPDATE_IN_PROGRESS")
+ }
+
+ @Test
+ fun `listeners only receive notifications for their registered stack`() {
+ val stack1Updates = mutableListOf()
+ val stack2Updates = mutableListOf()
+
+ val listener1 = object : StackPanelListener {
+ override fun onStackUpdated() {
+ stack1Updates.add("updated")
+ }
+ }
+
+ val listener2 = object : StackPanelListener {
+ override fun onStackUpdated() {
+ stack2Updates.add("updated")
+ }
+ }
+
+ coordinator.addListener(testStackArn1, listener1)
+ coordinator.addListener(testStackArn2, listener2)
+
+ coordinator.setStack(testStackArn1, "stack-1")
+ coordinator.setStack(testStackArn2, "stack-2")
+ coordinator.updateStackStatus(testStackArn1, "COMPLETE")
+ coordinator.updateStackStatus(testStackArn2, "FAILED")
+
+ // Each listener should receive 2 notifications (setStack + updateStackStatus)
+ assertThat(stack1Updates).hasSize(2)
+ assertThat(stack2Updates).hasSize(2)
+ }
+
+ @Test
+ fun `new listeners receive immediate notification of current state`() {
+ coordinator.setStack(testStackArn1, "existing-stack")
+ coordinator.updateStackStatus(testStackArn1, "CREATE_COMPLETE")
+
+ var notificationCount = 0
+
+ val listener = object : StackPanelListener {
+ override fun onStackUpdated() {
+ notificationCount++
+ }
+ }
+
+ // Listener should immediately receive current state
+ coordinator.addListener(testStackArn1, listener)
+
+ assertThat(notificationCount).isEqualTo(1)
+ }
+
+ @Test
+ fun `removeStack cleans up state and listeners`() {
+ coordinator.setStack(testStackArn1, "stack-1")
+ coordinator.addListener(
+ testStackArn1,
+ object : StackPanelListener {
+ override fun onStackUpdated() {}
+ }
+ )
+
+ assertThat(coordinator.getStackState(testStackArn1)).isNotNull()
+
+ coordinator.removeStack(testStackArn1)
+
+ assertThat(coordinator.getStackState(testStackArn1)).isNull()
+ }
+
+ @Test
+ fun `listener disposal removes listener for specific stack`() {
+ var notificationCount = 0
+
+ val listener = object : StackPanelListener {
+ override fun onStackUpdated() {
+ notificationCount++
+ }
+ }
+
+ val disposable = coordinator.addListener(testStackArn1, listener)
+ coordinator.setStack(testStackArn1, "test")
+ assertThat(notificationCount).isEqualTo(1)
+
+ disposable.dispose()
+ coordinator.setStack(testStackArn1, "test-updated")
+ assertThat(notificationCount).isEqualTo(1) // Should not increment
+ }
+
+ @Test
+ fun `dispose clears all stacks and listeners`() {
+ coordinator.setStack(testStackArn1, "stack-1")
+ coordinator.setStack(testStackArn2, "stack-2")
+
+ assertThat(coordinator.getStackState(testStackArn1)).isNotNull()
+ assertThat(coordinator.getStackState(testStackArn2)).isNotNull()
+
+ coordinator.dispose()
+
+ assertThat(coordinator.getStackState(testStackArn1)).isNull()
+ assertThat(coordinator.getStackState(testStackArn2)).isNull()
+ }
+}
diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt
new file mode 100644
index 00000000000..19f5c910bc1
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/stacks/views/UtilsTest.kt
@@ -0,0 +1,156 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.stacks.views
+
+import com.intellij.ui.JBColor
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+
+class UtilsTest {
+
+ @Nested
+ inner class StackStatusUtilsTest {
+
+ @Test
+ fun `getStatusColors returns green for COMPLETE statuses`() {
+ val testCases = listOf(
+ "CREATE_COMPLETE",
+ "UPDATE_COMPLETE",
+ "DELETE_COMPLETE"
+ )
+
+ testCases.forEach { status ->
+ val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status)
+ assertThat(bgColor).isEqualTo(JBColor.GREEN)
+ assertThat(fgColor).isEqualTo(JBColor.BLACK)
+ }
+ }
+
+ @Test
+ fun `getStatusColors returns red for FAILED and ROLLBACK statuses`() {
+ val testCases = listOf(
+ "CREATE_FAILED",
+ "UPDATE_FAILED",
+ "ROLLBACK_COMPLETE",
+ "UPDATE_ROLLBACK_COMPLETE"
+ )
+
+ testCases.forEach { status ->
+ val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status)
+ assertThat(bgColor).isEqualTo(JBColor.RED)
+ assertThat(fgColor).isEqualTo(JBColor.BLACK)
+ }
+ }
+
+ @Test
+ fun `getStatusColors returns yellow for PROGRESS statuses`() {
+ val testCases = listOf(
+ "CREATE_IN_PROGRESS",
+ "UPDATE_IN_PROGRESS",
+ "DELETE_IN_PROGRESS"
+ )
+
+ testCases.forEach { status ->
+ val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status)
+ assertThat(bgColor).isEqualTo(JBColor.YELLOW)
+ assertThat(fgColor).isEqualTo(JBColor.BLACK)
+ }
+ }
+
+ @Test
+ fun `getStatusColors returns null for unknown statuses`() {
+ val testCases = listOf("UNKNOWN_STATUS", "", "RANDOM_TEXT")
+
+ testCases.forEach { status ->
+ val (bgColor, fgColor) = StackStatusUtils.getStatusColors(status)
+ assertThat(bgColor).isNull()
+ assertThat(fgColor).isNull()
+ }
+ }
+
+ @Test
+ fun `isInTransientState returns true for IN_PROGRESS statuses`() {
+ val testCases = listOf(
+ "CREATE_IN_PROGRESS",
+ "UPDATE_IN_PROGRESS",
+ "DELETE_IN_PROGRESS",
+ "UPDATE_CLEANUP_IN_PROGRESS"
+ )
+
+ testCases.forEach { status ->
+ assertThat(StackStatusUtils.isInTransientState(status)).isTrue()
+ }
+ }
+
+ @Test
+ fun `isInTransientState returns false for terminal statuses`() {
+ val testCases = listOf(
+ "CREATE_COMPLETE",
+ "UPDATE_COMPLETE",
+ "CREATE_FAILED",
+ "ROLLBACK_COMPLETE"
+ )
+
+ testCases.forEach { status ->
+ assertThat(StackStatusUtils.isInTransientState(status)).isFalse()
+ }
+ }
+ }
+
+ @Nested
+ inner class StackDateFormatterTest {
+
+ @Test
+ fun `formatDate formats valid ISO date string`() {
+ val isoDate = "2024-01-15T10:30:45Z"
+ val result = StackDateFormatter.formatDate(isoDate)
+
+ // Should format to d/M/yyyy, h:mm:ss a pattern (timezone-dependent)
+ assertThat(result).isNotNull()
+ assertThat(result!!).contains("15/1/2024")
+ assertThat(result).satisfiesAnyOf(
+ { assertThat(it).contains("AM") },
+ { assertThat(it).contains("PM") }
+ )
+ assertThat(result).contains(":30:45")
+ }
+
+ @Test
+ fun `formatDate formats date with milliseconds`() {
+ val isoDate = "2024-12-25T23:59:59.123Z"
+ val result = StackDateFormatter.formatDate(isoDate)
+
+ assertThat(result).isNotNull()
+ assertThat(result!!).contains("25/12/2024")
+ assertThat(result).satisfiesAnyOf(
+ { assertThat(it).contains("AM") },
+ { assertThat(it).contains("PM") }
+ )
+ assertThat(result).contains(":59:59")
+ }
+
+ @Test
+ fun `formatDate returns null for invalid date`() {
+ val invalidDate = "not-a-date"
+ val result = StackDateFormatter.formatDate(invalidDate)
+
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `formatDate returns null for empty string`() {
+ val result = StackDateFormatter.formatDate("")
+ assertThat(result).isNull()
+ }
+
+ @Test
+ fun `formatDate returns null for malformed ISO date`() {
+ val malformedDate = "2024-13-45T25:70:80Z"
+ val result = StackDateFormatter.formatDate(malformedDate)
+
+ assertThat(result).isNull()
+ }
+ }
+}
diff --git a/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt
new file mode 100644
index 00000000000..1cb321c32f5
--- /dev/null
+++ b/plugins/toolkit/jetbrains-core/tst-253+/software/aws/toolkits/jetbrains/services/cfnlsp/ui/UtilsTest.kt
@@ -0,0 +1,62 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.cfnlsp.ui
+
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+
+class UtilsTest {
+
+ @Test
+ fun `generateUrl creates correct AWS console URL`() {
+ val stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/my-stack/" +
+ "12345678-1234-1234-1234-123456789012"
+ val result = ConsoleUrlGenerator.generateUrl(stackId)
+
+ val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" +
+ "arn%3Aaws%3Acloudformation%3Aus-east-1%3A123456789012%3Astack%2Fmy-stack%2F" +
+ "12345678-1234-1234-1234-123456789012"
+ assertThat(result).isEqualTo(expectedUrl)
+ }
+
+ @Test
+ fun `generateUrl handles special characters in stack name`() {
+ val stackId = "arn:aws:cloudformation:us-west-2:123456789012:stack/" +
+ "my-stack-with-dashes_and_underscores/12345"
+ val result = ConsoleUrlGenerator.generateUrl(stackId)
+
+ val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" +
+ "arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2F" +
+ "my-stack-with-dashes_and_underscores%2F12345"
+ assertThat(result).isEqualTo(expectedUrl)
+ }
+
+ @Test
+ fun `generateUrl handles different regions`() {
+ val stackId = "arn:aws:cloudformation:eu-west-1:123456789012:stack/test-stack/abcdef"
+ val result = ConsoleUrlGenerator.generateUrl(stackId)
+
+ val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" +
+ "arn%3Aaws%3Acloudformation%3Aeu-west-1%3A123456789012%3Astack%2Ftest-stack%2Fabcdef"
+ assertThat(result).isEqualTo(expectedUrl)
+ }
+
+ @Test
+ fun `generateUrl handles empty string`() {
+ val result = ConsoleUrlGenerator.generateUrl("")
+ assertThat(result).isEqualTo("https://console.aws.amazon.com/go/view?arn=")
+ }
+
+ @Test
+ fun `generateUrl handles spaces and special characters`() {
+ val stackId = "arn:aws:cloudformation:us-east-1:123456789012:stack/" +
+ "stack with spaces & symbols/12345"
+ val result = ConsoleUrlGenerator.generateUrl(stackId)
+
+ val expectedUrl = "https://console.aws.amazon.com/go/view?arn=" +
+ "arn%3Aaws%3Acloudformation%3Aus-east-1%3A123456789012%3Astack%2F" +
+ "stack+with+spaces+%26+symbols%2F12345"
+ assertThat(result).isEqualTo(expectedUrl)
+ }
+}