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) + } +}